using System.Reflection;
using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;

namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Tests.Scripts;

/// <summary>
/// M3.3: compile-surface parity guard. The shared
/// <see cref="ScriptCompileSurface"/> / <see cref="TriggerCompileSurface"/> are
/// compile-only stubs the design-time deploy gate binds candidate scripts
/// against; they must mirror the real SiteRuntime <see cref="ScriptGlobals"/> /
/// <see cref="TriggerExpressionGlobals"/> bind surfaces. If a member a script can
/// reference on the real globals is missing from the compile surface, a script
/// that uses it would pass the design-time gate but fail at the site — so the
/// compile surface's public top-level member NAMES must be a SUPERSET of the
/// real globals' names. This test fails loudly (listing the missing names) when
/// the surface drifts behind the runtime globals.
///
/// <para>
/// Top-level member-name parity is sufficient: the design-time compile against
/// the surface catches deeper signature mismatches itself; this guard only
/// ensures every entry point a script can name on the real globals exists on the
/// stub.
/// </para>
/// </summary>
public class CompileSurfaceParityTests
{
    [Fact]
    public void ScriptCompileSurface_MemberNames_AreSupersetOf_ScriptGlobals()
    {
        AssertSurfaceCoversGlobals(
            surface: typeof(ScriptCompileSurface),
            globals: typeof(ScriptGlobals));
    }

    [Fact]
    public void TriggerCompileSurface_MemberNames_AreSupersetOf_TriggerExpressionGlobals()
    {
        AssertSurfaceCoversGlobals(
            surface: typeof(TriggerCompileSurface),
            globals: typeof(TriggerExpressionGlobals));
    }

    /// <summary>
    /// Deeper guard than the top-level name-superset check above: for each
    /// runtime script-accessor type (<see cref="AttributeAccessor"/>,
    /// <see cref="CompositionAccessor"/>, <see cref="ChildrenAccessor"/>) every
    /// public instance METHOD must have a same-name / same-arity counterpart on
    /// its compile-surface mirror. This catches the regression that motivated this
    /// test (the <c>WaitAsync</c> overloads were present on the runtime
    /// <see cref="AttributeAccessor"/> but missing from
    /// <see cref="ScriptCompileSurface.CompileAttributeAccessor"/>, so scripts that
    /// awaited them passed the design-time gate yet would have failed at the site).
    ///
    /// <para>
    /// Matching is by NAME + PARAMETER COUNT only — the mirror uses
    /// <c>object?</c>-vs-mirror-type substitutions, so exact parameter-type identity
    /// is intentionally NOT required; the guard is about API presence, not type
    /// equality (the design-time compile catches deeper signature drift itself).
    /// To still catch a single dropped overload within an overload set that shares
    /// an arity (e.g. the value-form vs predicate-form <c>WaitAsync</c>, both
    /// 4-parameter), the assertion is by COUNT: the mirror must expose at least as
    /// many overloads for each (name, arity) as the runtime accessor declares.
    /// </para>
    /// </summary>
    [Theory]
    [MemberData(nameof(AccessorMirrorPairs))]
    public void CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities(
        Type runtimeAccessor, Type compileMirror)
    {
        var mirrorCounts = PublicInstanceMethodArityCounts(compileMirror);
        var runtimeCounts = PublicInstanceMethodArityCounts(runtimeAccessor);

        var shortfalls = runtimeCounts
            .Where(kvp => mirrorCounts.GetValueOrDefault(kvp.Key) < kvp.Value)
            .OrderBy(kvp => kvp.Key.Name, StringComparer.Ordinal)
            .ThenBy(kvp => kvp.Key.Arity)
            .Select(kvp =>
                $"{kvp.Key.Name}({kvp.Key.Arity} param(s)): runtime has {kvp.Value} "
                + $"overload(s), mirror has {mirrorCounts.GetValueOrDefault(kvp.Key)}")
            .ToList();

        Assert.True(
            shortfalls.Count == 0,
            $"Compile surface mirror '{compileMirror.Name}' under-covers {shortfalls.Count} "
            + $"method group(s) on runtime accessor '{runtimeAccessor.Name}': "
            + $"{string.Join("; ", shortfalls)}. "
            + "The compile-only mirror must expose a same-name / same-arity method for "
            + "every public instance method a script can call on the runtime accessor — "
            + "add the missing method(s) to the ScriptAnalysis compile surface so a "
            + "script using them cannot pass the design-time gate then fail at the site.");
    }

    /// <summary>
    /// Runtime script-accessor type ↔ compile-surface mirror pairs guarded by
    /// <see cref="CompileAccessorMirror_Covers_RuntimeAccessor_MethodArities"/>.
    /// </summary>
    public static IEnumerable<object[]> AccessorMirrorPairs() =>
        new[]
        {
            new object[] { typeof(AttributeAccessor), typeof(ScriptCompileSurface.CompileAttributeAccessor) },
            new object[] { typeof(CompositionAccessor), typeof(ScriptCompileSurface.CompileCompositionAccessor) },
            // ChildrenAccessor currently exposes only an indexer (no public instance
            // methods), so this row guards nothing today. It is kept so that if a
            // callable method is ever added to ChildrenAccessor, this Theory begins
            // enforcing a mirror for it automatically — rather than silently
            // re-opening the WaitAsync-class gap.
            new object[] { typeof(ChildrenAccessor), typeof(ScriptCompileSurface.CompileChildrenAccessor) },
            // MES alarm-status API §5.2: the Alarms accessor joins the guarded set so a
            // future read method (e.g. an ActiveAsync convenience) cannot ship on the
            // runtime accessor while the design-time gate stays blind to it.
            new object[] { typeof(AlarmsAccessor), typeof(ScriptCompileSurface.CompileAlarmsAccessor) },
        };

    /// <summary>
    /// Asserts that the public instance property + method member names of
    /// <paramref name="surface"/> are a superset of those of
    /// <paramref name="globals"/>. Inherited <see cref="object"/> members
    /// (ToString/GetHashCode/Equals/GetType) are excluded. Fails with the exact
    /// list of missing names when the surface does not cover the globals.
    /// </summary>
    private static void AssertSurfaceCoversGlobals(Type surface, Type globals)
    {
        var surfaceNames = PublicInstanceMemberNames(surface);
        var globalsNames = PublicInstanceMemberNames(globals);

        var missing = globalsNames
            .Where(name => !surfaceNames.Contains(name))
            .OrderBy(name => name, StringComparer.Ordinal)
            .ToList();

        Assert.True(
            missing.Count == 0,
            $"Compile surface '{surface.Name}' is missing {missing.Count} member name(s) "
            + $"present on '{globals.Name}': {string.Join(", ", missing)}. "
            + "The compile-only surface must mirror the runtime globals — add the "
            + "missing member(s) to the ScriptAnalysis surface type.");
    }

    /// <summary>
    /// Public instance property + method member names declared on or inherited by
    /// <paramref name="type"/>, excluding the inherited <see cref="object"/>
    /// members (ToString, GetHashCode, Equals, GetType) so only script-reachable
    /// API names remain. Property/method names are compared (no signatures).
    /// </summary>
    private static HashSet<string> PublicInstanceMemberNames(Type type)
    {
        const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;

        var names = new HashSet<string>(StringComparer.Ordinal);

        foreach (var property in type.GetProperties(flags))
            names.Add(property.Name);

        foreach (var method in type.GetMethods(flags))
        {
            // Skip compiler-generated property accessors (get_X / set_X) — the
            // property itself is already counted by name above.
            if (method.IsSpecialName)
                continue;
            if (ObjectMethodNames.Contains(method.Name))
                continue;
            names.Add(method.Name);
        }

        return names;
    }

    /// <summary>
    /// Names of the inherited <see cref="object"/> instance methods that are not
    /// part of any script-reachable accessor API and must be excluded from parity.
    /// </summary>
    private static readonly HashSet<string> ObjectMethodNames = new(StringComparer.Ordinal)
    {
        nameof(ToString),
        nameof(GetHashCode),
        nameof(Equals),
        nameof(GetType),
    };

    /// <summary>
    /// (Name, parameter count) of each public instance METHOD declared on or
    /// inherited by <paramref name="type"/>, excluding inherited <see cref="object"/>
    /// methods and compiler-generated special-name methods (property/indexer
    /// get_/set_ accessors, operators). These are the methods a script can call
    /// directly on the accessor, the surface this guard requires the mirror to cover.
    /// </summary>
    private static IEnumerable<(string Name, int Arity)> PublicInstanceMethodSignatures(Type type)
    {
        const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;

        foreach (var method in type.GetMethods(flags))
        {
            if (method.IsSpecialName)
                continue;
            if (ObjectMethodNames.Contains(method.Name))
                continue;
            yield return (method.Name, method.GetParameters().Length);
        }
    }

    /// <summary>
    /// Number of public instance method overloads on <paramref name="type"/> per
    /// (Name, parameter count) key — used to test method-arity parity against a
    /// runtime accessor by count (so a single dropped overload within a shared-arity
    /// overload set is caught, not just a wholly-missing method group). Built with
    /// <see cref="PublicInstanceMethodSignatures"/> so the same object/special-name
    /// exclusions apply on both sides.
    /// </summary>
    private static Dictionary<(string Name, int Arity), int> PublicInstanceMethodArityCounts(Type type) =>
        PublicInstanceMethodSignatures(type)
            .GroupBy(sig => sig)
            .ToDictionary(g => g.Key, g => g.Count());
}
