using System.Security.Claims;
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Security;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
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.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
using TopologyPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.Topology;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests;

/// <summary>
/// bUnit rendering tests for the Topology page (Site → Area → Instance tree).
/// Focuses on the behavior that's specific to this page:
/// always-visible empty containers, search dimming, F2 area rename, and the
/// move-dialog destination lists.
/// </summary>
public class TopologyPageTests : BunitContext
{
    private readonly ITemplateEngineRepository _repo = Substitute.For<ITemplateEngineRepository>();
    private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
    private readonly IDeploymentManagerRepository _deployRepo = Substitute.For<IDeploymentManagerRepository>();
    private readonly IFlatteningPipeline _pipeline = Substitute.For<IFlatteningPipeline>();
    private readonly IAuditService _audit = Substitute.For<IAuditService>();

    public TopologyPageTests()
    {
        Services.AddSingleton(_repo);
        Services.AddSingleton(_siteRepo);
        Services.AddSingleton(_deployRepo);
        Services.AddSingleton(_pipeline);
        Services.AddSingleton(_audit);

        // DeploymentService has non-mockable concrete deps; instantiate them directly.
        var comms = new CommunicationService(
            Options.Create(new CommunicationOptions()),
            NullLogger<CommunicationService>.Instance);
        Services.AddSingleton(comms);

        Services.AddSingleton(new OperationLockManager());
        Services.AddSingleton(Options.Create(new DeploymentManagerOptions
        {
            OperationLockTimeout = TimeSpan.FromSeconds(5)
        }));
        // Notify-and-fetch (Task 6): DeploymentService now also depends on
        // IOptions<CommunicationOptions>; register it so the page's DI graph resolves.
        Services.AddSingleton(Options.Create(new CommunicationOptions()));
        Services.AddSingleton<ILogger<DeploymentService>>(NullLogger<DeploymentService>.Instance);
        // DeploymentService gained a DiffService dependency (DeploymentManager
        // contract change); register it so the page's DI graph resolves.
        Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.DiffService>();
        // I-1: DeploymentService also recomputes the deployed-side staleness hash
        // from the (List-normalized) snapshot via RevisionHashService; register it
        // so the page's DI graph resolves.
        Services.AddScoped<ZB.MOM.WW.ScadaBridge.TemplateEngine.Flattening.RevisionHashService>();
        // CentralUI-006: DeploymentService now also depends on the
        // deployment-status notifier (a process singleton in production).
        Services.AddSingleton<ZB.MOM.WW.ScadaBridge.DeploymentManager.IDeploymentStatusNotifier>(
            new ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier(
                NullLogger<ZB.MOM.WW.ScadaBridge.DeploymentManager.DeploymentStatusNotifier>.Instance));
        Services.AddScoped<DeploymentService>();
        Services.AddScoped<AreaService>();
        Services.AddScoped<InstanceService>();

        AddTestAuth();

        // The page injects IDialogService for delete confirmations; the host
        // (rendered globally in MainLayout) is not present in bUnit, but the
        // DI registration still has to satisfy the [Inject].
        Services.AddScoped<IDialogService, DialogService>();

        // Site scoping (CentralUI-002): Topology injects SiteScopeService to
        // filter the tree by the user's permitted sites.
        Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();

        // TreeView persists expansion state via JS interop. Stub the calls so render doesn't throw.
        JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult(null);
        JSInterop.SetupVoid("treeviewStorage.save", _ => true);
    }

    private void AddTestAuth()
    {
        var claims = new[]
        {
            new Claim(JwtTokenService.UsernameClaimType, "tester"),
            new Claim(JwtTokenService.RoleClaimType, "Deployer")
        };
        var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
        Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(user));
        Services.AddAuthorizationCore();
    }

    private void SeedRepos(
        IEnumerable<Site>? sites = null,
        IEnumerable<Template>? templates = null,
        IEnumerable<Instance>? instances = null,
        Dictionary<int, IReadOnlyList<Area>>? areasBySite = null)
    {
        _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Site>>(sites?.ToList() ?? new List<Site>()));
        _repo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Template>>(templates?.ToList() ?? new List<Template>()));
        _repo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<Instance>>(instances?.ToList() ?? new List<Instance>()));

        areasBySite ??= new();
        _repo.GetAreasBySiteIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
            .Returns(call =>
            {
                var sid = call.Arg<int>();
                return Task.FromResult(areasBySite.TryGetValue(sid, out var list)
                    ? list
                    : (IReadOnlyList<Area>)new List<Area>());
            });
    }

    [Fact]
    public void Renders_EmptyState_WhenNoSites()
    {
        SeedRepos();

        var cut = Render<TopologyPage>();

        Assert.Contains("No sites configured", cut.Markup);
    }

    [Fact]
    public void Renders_EmptySite_AsTopLevelNode()
    {
        // An always-show-empty container is a hard requirement: a site with nothing
        // under it must still appear so users can move/create into it.
        SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } });

        var cut = Render<TopologyPage>();

        Assert.Contains("Plant-A", cut.Markup);
        Assert.Contains("bi-building", cut.Markup);
    }

    private static AngleSharp.Dom.IElement? FindToggleForLabel(IRenderedComponent<TopologyPage> cut, string label) =>
        cut.FindAll(".tv-row")
            .FirstOrDefault(row => row.QuerySelector(".tv-label")?.TextContent == label)
            ?.QuerySelector(".tv-toggle");

    [Fact]
    public void Renders_SiteAreaInstance_Nesting()
    {
        var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
        {
            [1] = new List<Area>
            {
                new("Line-1") { Id = 10, SiteId = 1, ParentAreaId = null }
            }
        };
        SeedRepos(
            sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
            instances: new[]
            {
                new Instance("Pump-001") { Id = 100, SiteId = 1, AreaId = 10, State = InstanceState.NotDeployed }
            },
            areasBySite: areasBySite);

        var cut = Render<TopologyPage>();

        // Expand the site, then the area, to render the instance leaf. The
        // helper scopes by the row's own label so we don't match outer rows
        // whose TextContent transitively contains the inner label.
        FindToggleForLabel(cut, "Plant-A")!.Click();
        FindToggleForLabel(cut, "Line-1")!.Click();

        Assert.Contains("Pump-001", cut.Markup);
        Assert.Contains("bi-diagram-3", cut.Markup);
        Assert.Contains("bi-box", cut.Markup);
        Assert.Contains("NotDeployed", cut.Markup);
    }

    [Fact]
    public void Search_DimsNonMatches_PreservesShape()
    {
        var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
        {
            [1] = new List<Area>
            {
                new("Line-1") { Id = 10, SiteId = 1 },
                new("Boilers") { Id = 11, SiteId = 1 }
            }
        };
        SeedRepos(
            sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
            areasBySite: areasBySite);

        var cut = Render<TopologyPage>();
        FindToggleForLabel(cut, "Plant-A")!.Click();

        var search = cut.Find("input[type='text']");
        search.Input("Line");

        // Both areas remain in the DOM (shape preserved). 'Boilers' gets the dim style.
        Assert.Contains("Line-1", cut.Markup);
        Assert.Contains("Boilers", cut.Markup);
        var dimmedNodes = cut.FindAll("span.tv-label[style*='opacity']");
        Assert.Contains(dimmedNodes, n => n.TextContent.Contains("Boilers"));
    }

    [Fact]
    public void SiteScoping_ScopedDeploymentUser_OnlySeesPermittedSites()
    {
        // Regression test for CentralUI-002. The SiteId claims issued at login were
        // never read, so a Deployment user scoped to one site could view (and act
        // on) every site's topology. Topology now filters the tree by the user's
        // permitted sites via SiteScopeService.
        var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
        {
            new Claim(JwtTokenService.UsernameClaimType, "scoped-tester"),
            new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.RoleClaimType, "Deployer"),
            // Permitted on site 1 only.
            new Claim(ZB.MOM.WW.ScadaBridge.Security.JwtTokenService.SiteIdClaimType, "1"),
        }, "TestAuth"));
        // Last AuthenticationStateProvider registration wins on resolution.
        Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));

        SeedRepos(sites: new[]
        {
            new Site("Plant-A", "plant-a") { Id = 1 },
            new Site("Plant-B", "plant-b") { Id = 2 },
        });

        var cut = Render<TopologyPage>();

        // The permitted site is rendered; the non-permitted site is not.
        Assert.Contains("Plant-A", cut.Markup);
        Assert.DoesNotContain("Plant-B", cut.Markup);
    }

    [Fact]
    public void SiteScoping_SystemWideDeploymentUser_SeesAllSites()
    {
        // A Deployment user with no SiteId claims is system-wide and sees every site.
        SeedRepos(sites: new[]
        {
            new Site("Plant-A", "plant-a") { Id = 1 },
            new Site("Plant-B", "plant-b") { Id = 2 },
        });

        var cut = Render<TopologyPage>();

        Assert.Contains("Plant-A", cut.Markup);
        Assert.Contains("Plant-B", cut.Markup);
    }

    [Fact]
    public void DoubleClick_OnAreaLabel_EntersRenameMode()
    {
        var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
        {
            [1] = new List<Area> { new("Line-1") { Id = 10, SiteId = 1 } }
        };
        SeedRepos(sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } }, areasBySite: areasBySite);

        var cut = Render<TopologyPage>();
        FindToggleForLabel(cut, "Plant-A")!.Click();

        var areaLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Line-1");
        areaLabel.DoubleClick();

        // Inline rename input replaces the label.
        Assert.NotNull(cut.Find("input.form-control-sm.d-inline-block"));
    }

    [Fact]
    public void InstanceRows_DoNotHaveDoubleClickRename()
    {
        // Instance rename is out of scope; the label should not have @ondblclick wired.
        // bUnit throws MissingEventHandlerException when dispatching to an element
        // that has no handler — that's the assertion: the dblclick event is not bound.
        SeedRepos(
            sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
            instances: new[]
            {
                new Instance("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.NotDeployed }
            });

        var cut = Render<TopologyPage>();
        FindToggleForLabel(cut, "Plant-A")!.Click();

        var instanceLabel = cut.FindAll("span.tv-label").First(s => s.TextContent == "Pump-001");
        Assert.Throws<Bunit.MissingEventHandlerException>(() => instanceLabel.DoubleClick());
    }

    [Fact]
    public void Diff_ConnectionEndpointChange_RendersConnectionSection()
    {
        // TemplateEngine-018 / DeploymentManager-018: a standalone connection
        // endpoint edit (no per-attribute binding change) must surface in the
        // deployment-diff modal. Before ConnectionChanges was wired through
        // ComputeDiff + the UI, this redeploy showed only the stale-hash badge
        // with no indication that the connection endpoint had moved.
        // The DiffDialog body-scroll lock + focus call out to JS interop on
        // open; loose mode no-ops the handlers we don't explicitly set up.
        JSInterop.Mode = JSRuntimeMode.Loose;

        var areasBySite = new Dictionary<int, IReadOnlyList<Area>>
        {
            [1] = new List<Area> { new("Line-1") { Id = 10, SiteId = 1 } }
        };
        SeedRepos(
            sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
            instances: new[]
            {
                new Instance("Pump-001") { Id = 100, SiteId = 1, AreaId = 10, State = InstanceState.Enabled }
            },
            areasBySite: areasBySite);

        // Deployed snapshot: connection "plc1" points at host-a.
        var deployedConfig = new FlattenedConfiguration
        {
            InstanceUniqueName = "Pump-001",
            Connections = new Dictionary<string, ConnectionConfig>
            {
                ["plc1"] = new ConnectionConfig
                {
                    Protocol = "OpcUa",
                    ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-a:4840\"}",
                    FailoverRetryCount = 3,
                }
            }
        };
        _deployRepo.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<DeployedConfigSnapshot?>(
                new DeployedConfigSnapshot("dep-1", "hash-old",
                    JsonSerializer.Serialize(deployedConfig))));

        // Current template-derived config: same connection now points at host-b.
        var currentConfig = new FlattenedConfiguration
        {
            InstanceUniqueName = "Pump-001",
            Connections = new Dictionary<string, ConnectionConfig>
            {
                ["plc1"] = new ConnectionConfig
                {
                    Protocol = "OpcUa",
                    ConfigurationJson = "{\"endpoint\":\"opc.tcp://host-b:4840\"}",
                    FailoverRetryCount = 3,
                }
            }
        };
        _pipeline.FlattenAndValidateAsync(100, Arg.Any<CancellationToken>(), Arg.Any<bool>())
            .Returns(Task.FromResult(Result<FlatteningPipelineResult>.Success(
                new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success()))));

        var cut = Render<TopologyPage>();
        FindToggleForLabel(cut, "Plant-A")!.Click();
        FindToggleForLabel(cut, "Line-1")!.Click();

        // The per-node action menu only renders after a context-menu (right
        // click) on the instance row, so open it first, then click "Diff".
        var instanceRow = cut.FindAll(".tv-row")
            .First(row => row.QuerySelector(".tv-label")?.TextContent == "Pump-001");
        instanceRow.ContextMenu();

        var diffButton = cut.FindAll("button.dropdown-item")
            .First(b => b.TextContent.Trim() == "Diff");
        diffButton.Click();

        var markup = cut.Markup;
        Assert.Contains("Connections", markup);
        Assert.Contains("plc1", markup);
        Assert.Contains("host-a", markup);
        Assert.Contains("host-b", markup);
        // The change is a modification, so the row carries the "Changed" badge.
        Assert.Contains("Changed", markup);
    }

    /// <summary>
    /// Seeds one site with one deployed (Enabled) instance whose deployment
    /// comparison resolves: a deployed snapshot plus a current flatten. Used by
    /// the staleness-lifecycle tests below, which assert on how OFTEN the
    /// comparison runs (via the snapshot repository call count), not on the
    /// resulting Stale/Current value.
    /// </summary>
    private void SeedOneDeployedInstanceWithComparison()
    {
        SeedRepos(
            sites: new[] { new Site("Plant-A", "plant-a") { Id = 1 } },
            instances: new[]
            {
                new Instance("Pump-001") { Id = 100, SiteId = 1, State = InstanceState.Enabled }
            });

        var deployedConfig = new FlattenedConfiguration { InstanceUniqueName = "Pump-001" };
        _deployRepo.GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<DeployedConfigSnapshot?>(
                new DeployedConfigSnapshot("dep-1", "hash-old",
                    JsonSerializer.Serialize(deployedConfig))));

        var currentConfig = new FlattenedConfiguration { InstanceUniqueName = "Pump-001" };
        _pipeline.FlattenAndValidateAsync(100, Arg.Any<CancellationToken>(), Arg.Any<bool>())
            .Returns(Task.FromResult(Result<FlatteningPipelineResult>.Success(
                new FlatteningPipelineResult(currentConfig, "hash-new", ValidationResult.Success()))));
    }

    [Fact]
    public async Task LiveUpdate_DoesNotRecomputeStaleness()
    {
        // Performance regression guard: staleness (the expensive per-instance
        // re-flatten via DeploymentService.GetDeploymentComparisonAsync) must NOT
        // run on the 15s live-update poll. The live-update path refreshes only the
        // cheap deployed state (LoadDeployedStateAsync), which the timer invokes
        // directly. The comparison reaches into the snapshot repository, so its
        // call count is the proxy for "did staleness recompute".
        SeedOneDeployedInstanceWithComparison();

        var cut = Render<TopologyPage>();

        // Initial load computed staleness exactly once.
        await _deployRepo.Received(1)
            .GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());

        // Drive the EXACT code path the live-update timer runs.
        await cut.InvokeAsync(() => cut.Instance.LoadDeployedStateAsync());

        // Staleness was NOT recomputed by the live-update refresh — still once.
        await _deployRepo.Received(1)
            .GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());
    }

    [Fact]
    public void DeployedState_Renders_AfterSplit()
    {
        // After splitting the load into fast-state vs expensive-staleness, the
        // cheap deployed state (hierarchy + State badge) must still render.
        SeedOneDeployedInstanceWithComparison();

        var cut = Render<TopologyPage>();
        FindToggleForLabel(cut, "Plant-A")!.Click();

        Assert.Contains("Pump-001", cut.Markup);
        Assert.Contains("Enabled", cut.Markup);
        // The deployed instance shows a staleness badge (Stale or Current).
        Assert.Matches("Stale|Current", cut.Markup);
    }

    [Fact]
    public void ManualRecheck_RecomputesStaleness()
    {
        // The "Re-check staleness" button lets operators refresh staleness on
        // demand (since it's off the live loop). Clicking it runs the comparison
        // again — a second snapshot-repository call.
        SeedOneDeployedInstanceWithComparison();

        var cut = Render<TopologyPage>();
        _deployRepo.Received(1)
            .GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());

        cut.Find("button[aria-label='Re-check staleness']").Click();

        _deployRepo.Received(2)
            .GetDeployedSnapshotByInstanceIdAsync(100, Arg.Any<CancellationToken>());
    }

    [Fact]
    public void LegacyInstancesRoute_IsDeclaredOnTopologyPage()
    {
        // Old bookmarks to /deployment/instances must still resolve. Reflection
        // check: the Topology component carries both @page directives.
        var pageAttrs = typeof(TopologyPage).GetCustomAttributes(
            typeof(Microsoft.AspNetCore.Components.RouteAttribute), inherit: false)
            .Cast<Microsoft.AspNetCore.Components.RouteAttribute>()
            .Select(a => a.Template)
            .ToList();

        Assert.Contains("/deployment/topology", pageAttrs);
        Assert.Contains("/deployment/instances", pageAttrs);
    }
}
