using System.Reflection;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Clusters;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Uns;
using ZB.MOM.WW.OtOpcUa.Security.Auth;

namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Authorization;

/// <summary>
///     Reflection guard over every routable AdminUI page's class-level authorization metadata.
///     This is the substitute for bUnit (the repo has no bUnit; razor <c>@attribute</c>/<c>@page</c>
///     compile to class-level attributes, so a metadata scan is authoritative and needs no rendering).
///     It is the anti-drift anchor: a new page fails until the author consciously classifies it.
///     Guard universe = <c>typeof(Routes).Assembly</c> — the exact assembly Routes.razor hands
///     <c>&lt;Router AppAssembly=…&gt;</c>, so the guard sees precisely the router's page set.
/// </summary>
public class PageAuthorizationGuardTests
{
    /// <summary>
    ///     Target classification: every routable page → its required <see cref="AdminUiPolicies"/> policy.
    ///     This is the 38-page census from the R2-05 plan. <see cref="AnonymousPages"/> holds the sole
    ///     <c>[AllowAnonymous]</c> page. Together they must exactly cover the router's page set.
    /// </summary>
    private static readonly IReadOnlyDictionary<Type, string> ExpectedPolicy = new Dictionary<Type, string>
    {
        // ── ConfigEditor (11): the config-authoring surface (incl. live ResilienceConfig).
        //    The routed /clusters/{id}/drivers* driver-authoring pages (DriverTypePicker,
        //    DriverEditRouter, + the 8 per-type *DriverPage shells) retired in B2-WP8 — driver
        //    authoring now flows through the /raw tree, so those routes no longer exist. ──
        [typeof(GlobalUns)] = AdminUiPolicies.ConfigEditor,
        [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Raw.GlobalRaw)] = AdminUiPolicies.ConfigEditor,
        [typeof(EquipmentPage)] = AdminUiPolicies.ConfigEditor,
        [typeof(NewCluster)] = AdminUiPolicies.ConfigEditor,
        [typeof(ClusterEdit)] = AdminUiPolicies.ConfigEditor,
        [typeof(NodeEdit)] = AdminUiPolicies.ConfigEditor,
        [typeof(NamespaceEdit)] = AdminUiPolicies.ConfigEditor,
        [typeof(AclEdit)] = AdminUiPolicies.ConfigEditor,
        [typeof(Deployments)] = AdminUiPolicies.ConfigEditor,
        [typeof(Scripts)] = AdminUiPolicies.ConfigEditor,
        [typeof(ScriptEdit)] = AdminUiPolicies.ConfigEditor,

        // ── FleetAdmin (1) ──
        [typeof(RoleGrants)] = AdminUiPolicies.FleetAdmin,

        // ── Secrets (1): host wrapper for the ZB.MOM.WW.Secrets.Ui RCL page (lmxopcua#483) —
        //    carries the RCL page's own policy, registered additively by AddAdminUI via
        //    AddSecretsAuthorization(). ──
        [typeof(SecretsAdmin)] = global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy,

        // ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ──
        [typeof(Home)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(ScriptLog)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(AlarmsHistorian)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(Account)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Certificates)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(Reservations)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(ClustersList)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(ClusterOverview)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(ClusterAudit)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(ClusterAcls)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(ClusterNamespaces)] = AdminUiPolicies.AuthenticatedRead,
        [typeof(ClusterRedundancy)] = AdminUiPolicies.AuthenticatedRead,
    };

    /// <summary>The only page that must remain anonymously reachable (Admin-001 — the login form).</summary>
    private static readonly IReadOnlySet<Type> AnonymousPages = new HashSet<Type> { typeof(Login) };

    private static readonly string[] KnownPolicyNames =
    [
        AdminUiPolicies.FleetAdmin,
        AdminUiPolicies.DriverOperator,
        AdminUiPolicies.ConfigEditor,
        AdminUiPolicies.AuthenticatedRead,
        // Not an AdminUiPolicies constant: the /admin/secrets wrapper re-states the Secrets.Ui
        // RCL page's own policy, which AddAdminUI registers via AddSecretsAuthorization().
        global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy,
    ];

    private static IReadOnlyList<Type> RoutablePages() =>
        [.. typeof(Routes).Assembly.GetTypes()
            .Where(t => t.GetCustomAttributes<RouteAttribute>(inherit: false).Any())
            .OrderBy(t => t.FullName, StringComparer.Ordinal)];

    private static string RouteOf(Type t) =>
        string.Join(", ", t.GetCustomAttributes<RouteAttribute>(inherit: false).Select(r => r.Template));

    private static AuthorizeAttribute? Authorize(Type t) =>
        t.GetCustomAttributes<AuthorizeAttribute>(inherit: false).SingleOrDefault();

    private static bool IsAnonymous(Type t) =>
        t.GetCustomAttributes<AllowAnonymousAttribute>(inherit: false).Any();

    /// <summary>
    ///     Rule 1 — no bare gates. Every routable page must carry either <c>[AllowAnonymous]</c> or an
    ///     <c>[Authorize]</c> with a non-empty Policy and null Roles. Bans bare <c>[Authorize]</c>, the
    ///     <c>Roles="…"</c> idiom, and attribute-less pages.
    /// </summary>
    [Fact]
    public void EveryRoutablePage_carriesAnExplicitNamedPolicyOrAllowAnonymous()
    {
        var offenders = new List<string>();
        foreach (var page in RoutablePages())
        {
            if (IsAnonymous(page))
                continue;

            var auth = Authorize(page);
            if (auth is null)
            {
                offenders.Add($"{page.FullName} ({RouteOf(page)}): no [Authorize]/[AllowAnonymous] attribute");
                continue;
            }

            if (string.IsNullOrEmpty(auth.Policy))
                offenders.Add($"{page.FullName} ({RouteOf(page)}): bare [Authorize] — no Policy");
            if (!string.IsNullOrEmpty(auth.Roles))
                offenders.Add($"{page.FullName} ({RouteOf(page)}): uses Roles=\"{auth.Roles}\" idiom — convert to a named policy");
        }

        offenders.ShouldBeEmpty(
            "Pages must carry an explicit named policy (or [AllowAnonymous]). Offenders:\n" +
            string.Join("\n", offenders));
    }

    /// <summary>
    ///     Rule 2 — exhaustive classification. The router's page set must equal the expected map plus the
    ///     anonymous allowlist, and every page's actual policy must match the expected one. A brand-new page
    ///     fails here until the author classifies it.
    /// </summary>
    [Fact]
    public void EveryRoutablePage_isClassifiedAndMatchesExpectedPolicy()
    {
        var routable = RoutablePages().ToHashSet();
        var classified = new HashSet<Type>(ExpectedPolicy.Keys);
        classified.UnionWith(AnonymousPages);

        var unclassified = routable.Except(classified)
            .Select(t => $"{t.FullName} ({RouteOf(t)})").OrderBy(s => s).ToList();
        unclassified.ShouldBeEmpty(
            "Routable pages missing from the guard's expected map — classify each in ExpectedPolicy or AnonymousPages:\n" +
            string.Join("\n", unclassified));

        var stale = classified.Except(routable)
            .Select(t => t.FullName ?? t.Name).OrderBy(s => s).ToList();
        stale.ShouldBeEmpty(
            "Guard expected-map entries that are no longer routable pages — remove them:\n" +
            string.Join("\n", stale));

        var mismatches = new List<string>();
        foreach (var (page, expected) in ExpectedPolicy)
        {
            var actual = Authorize(page)?.Policy;
            if (actual != expected)
                mismatches.Add($"{page.FullName} ({RouteOf(page)}): expected policy '{expected}', got '{actual ?? "<none>"}'");
        }

        mismatches.ShouldBeEmpty(
            "Pages whose actual policy differs from the expected classification:\n" +
            string.Join("\n", mismatches));
    }

    /// <summary>Rule 3 — every Policy value in use is one of the four known <see cref="AdminUiPolicies"/> constants.</summary>
    [Fact]
    public void EveryPolicyNameInUse_isAKnownAdminUiPolicy()
    {
        var unknown = new List<string>();
        foreach (var page in RoutablePages())
        {
            var policy = Authorize(page)?.Policy;
            if (!string.IsNullOrEmpty(policy) && !KnownPolicyNames.Contains(policy))
                unknown.Add($"{page.FullName} ({RouteOf(page)}): unknown policy '{policy}'");
        }

        unknown.ShouldBeEmpty(
            "Pages referencing a policy name not defined in AdminUiPolicies:\n" +
            string.Join("\n", unknown));
    }
}
