Editor/Contracts/ContractScanner.cs

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using Editor;
using Microsoft.AspNetCore.Components;
using Sandbox;
using Sandbox.UI;
using Grains.RazorDesigner.Document;

namespace Grains.RazorDesigner.Contracts;

public static class ContractScanner
{
    private const string LogPrefix = "[Grains.RazorDesigner]";

    private static readonly Dictionary<ControlType, (string Tag, string Icon, bool IsContainer)> _seed =
        new()
        {
            // Layout
            { ControlType.Panel,          ( "div",         "crop_square",     true  ) },
            { ControlType.SplitContainer, ( "split",       "vertical_split",  true  ) },
            // Display
            { ControlType.Label,          ( "label",       "text_fields",     false ) },
            { ControlType.Image,          ( "image",       "image",           false ) },
            { ControlType.IconPanel,      ( "i",           "star",            false ) },
            // Input
            { ControlType.Button,         ( "button",      "smart_button",    false ) },
            { ControlType.ButtonGroup,    ( "buttongroup", "view_carousel",   true  ) },
            { ControlType.Checkbox,       ( "checkbox",    "check_box",       false ) },
            { ControlType.TextEntry,      ( "textentry",   "input",           false ) },
            { ControlType.DropDown,       ( "dropdown",    "arrow_drop_down", false ) },
            // Form
            { ControlType.Form,           ( "form",        "list_alt",        true  ) },
            { ControlType.Field,          ( "field",       "label",           true  ) },
            { ControlType.FieldControl,   ( "control",     "widgets",         true  ) },
        };

    // The frozen table. Null until first access. Written once (idempotent after).
    private static IContractTable _table;

    public static IContractTable Table => EnsureLoaded();

    private static IContractTable EnsureLoaded()
    {
        if ( _table is not null ) return _table;

        Log.Info( $"{LogPrefix} ContractScanner: building contract table…" );

        var built = new Dictionary<ControlType, ControlContract>();
        foreach ( var kind in Enum.GetValues<ControlType>() )
        {
            try
            {
                built[kind] = BuildContract( kind );
            }
            catch ( Exception ex )
            {
                Log.Error( $"{LogPrefix} ContractScanner: failed to build contract for {kind}: {ex.Message}" );
                throw;
            }
        }

        ApplyOverlay( built );

        var frozen = new ReadOnlyDictionary<ControlType, ControlContract>( built );
        _table = new ContractTable( frozen );

        Log.Info( $"{LogPrefix} ContractScanner: {built.Count} contracts built. Per-kind field counts: " +
            string.Join( ", ", built.Values.Select( c => $"{c.Kind}:{c.PayloadFields.Count}f" ) ) );

        return _table;
    }


    private static ControlContract BuildContract( ControlType kind )
    {
        // 1. Resolve the Sandbox.UI engine type for this kind.
        var engineType = ResolveEngineType( kind );

        var engineFields = WalkEngineParameters( engineType );

        var payloadType  = PayloadFactory.Default( kind ).GetType();
        var recordFields = WalkRecordGroups( payloadType, kind );

        // 4. Merge: payload-record [Group] wins (Design note 6 / spec §M6.3).
        var fields = MergeFields( engineFields, recordFields );

        // 5. IsContainer — seeded from _seed map (documented limit 6; was ControlMetadata).
        var isContainer = _seed[kind].IsContainer;

        var icon = ResolveInspectorIcon( engineType, kind );

        // 7. Slots — empty until M6.2's overlay adds them (e.g. SplitContainer Left/Right).
        var slots = Array.Empty<SlotDefinition>();

        // 8. PreviewStrategy (reserved-but-inert in v1 — documented limit 8).
        var preview = kind switch
        {
            ControlType.Button or ControlType.TextEntry or ControlType.Checkbox => PreviewStrategy.LabelSubstitute,
            ControlType.IconPanel => PreviewStrategy.IconGlyph,
            _ => PreviewStrategy.Native,
        };

        // 9. LibraryTag — seeded from _seed map (documented limit 7; was ControlMetadata).
        var libraryTag = _seed[kind].Tag;

        Log.Info( $"{LogPrefix} ContractScanner.BuildContract({kind}): engineType={engineType?.FullName ?? "<not found>"} " +
            $"tag='{libraryTag}' container={isContainer} preview={preview} fields={fields.Count}" );

        return new ControlContract(
            Kind:           kind,
            PayloadType:    payloadType,
            LibraryTag:     libraryTag,
            IsContainer:    isContainer,
            InspectorIcon:  icon,
            Slots:          new ReadOnlyCollection<SlotDefinition>( (SlotDefinition[])slots.Clone() ),
            PayloadFields:  new ReadOnlyCollection<ContractField>( fields ),
            PreviewStrategy: preview );
    }


    private static Type ResolveEngineType( ControlType kind )
    {
        var tag = _seed[kind].Tag;

        var found = EditorTypeLibrary.GetTypes()
            .FirstOrDefault( td =>
            {
                var lib = td.GetAttribute<LibraryAttribute>();
                if ( lib == null ) return false;
                // Match the Name (the tag string passed to [Library("tag")]) case-insensitively.
                return string.Equals( lib.Name, tag, StringComparison.OrdinalIgnoreCase );
            } );

        if ( found is not null )
            return found.TargetType;

        var fallback = FallbackEngineTypeMap( kind );
        if ( fallback is not null )
        {
            Log.Warning( $"{LogPrefix} ContractScanner.ResolveEngineType({kind}): [Library('{tag}')] " +
                $"reverse-lookup found nothing — using hardcoded fallback {fallback.FullName}. " +
                $"Check if the engine [Library] tag changed." );
            return fallback;
        }

        Log.Warning( $"{LogPrefix} ContractScanner.ResolveEngineType({kind}): no type found for tag '{tag}' " +
            $"and no hardcoded fallback. Engine parameters for this kind will be empty." );
        return null;
    }

    private static Type FallbackEngineTypeMap( ControlType kind ) => kind switch
    {
        ControlType.Panel          => typeof( Sandbox.UI.Panel ),
        ControlType.SplitContainer => typeof( Sandbox.UI.SplitContainer ),
        ControlType.Label          => typeof( Sandbox.UI.Label ),
        ControlType.Image          => typeof( Sandbox.UI.Image ),
        ControlType.IconPanel      => typeof( Sandbox.UI.IconPanel ),
        ControlType.Button         => typeof( Sandbox.UI.Button ),
        ControlType.ButtonGroup    => typeof( Sandbox.UI.ButtonGroup ),
        ControlType.Checkbox       => typeof( Sandbox.UI.Checkbox ),
        ControlType.TextEntry      => typeof( Sandbox.UI.TextEntry ),
        ControlType.DropDown       => typeof( Sandbox.UI.DropDown ),
        ControlType.Form           => typeof( Sandbox.UI.Form ),
        ControlType.Field          => typeof( Sandbox.UI.Field ),
        ControlType.FieldControl   => typeof( Sandbox.UI.FieldControl ),
        _ => null,
    };


    private static List<ContractField> WalkEngineParameters( Type engineType )
    {
        if ( engineType is null ) return new List<ContractField>();

        var results  = new List<ContractField>();
        var seenNames = new HashSet<string>( StringComparer.Ordinal );

        // Walk from the concrete type up through the inheritance chain.
        var current = engineType;
        while ( current != null && current != typeof( object ) )
        {
            foreach ( var prop in current.GetProperties( BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly ) )
            {
                // Already collected from a more-derived override — skip.
                if ( !seenNames.Add( prop.Name ) ) continue;

                // Must have [Parameter] (in Microsoft.AspNetCore.Components).
                var paramAttr = prop.GetCustomAttribute<ParameterAttribute>( false );
                if ( paramAttr is null ) continue;

                // Skip RenderFragment / RenderFragment<T> (documented limit 1).
                if ( IsRenderFragment( prop.PropertyType ) ) continue;

                // Skip Func<> / Action<> delegates (documented limit 2).
                if ( IsFuncOrAction( prop.PropertyType ) ) continue;

                // Engine [Parameter] properties don't carry [Group] — that's our authoring-side.
                results.Add( new ContractField(
                    Name:         prop.Name,
                    ClrType:      prop.PropertyType,
                    Group:        "",
                    IsOverrideGate: false,
                    GatedByGroup: "" ) );
            }

            current = current.BaseType;
        }

        return results;
    }

    private static bool IsRenderFragment( Type t )
    {
        if ( t == typeof( Microsoft.AspNetCore.Components.RenderFragment ) ) return true;
        if ( t.IsGenericType && t.GetGenericTypeDefinition() == typeof( Microsoft.AspNetCore.Components.RenderFragment<> ) ) return true;
        return false;
    }

    private static bool IsFuncOrAction( Type t )
    {
        if ( t == typeof( System.Action ) ) return true;

        if ( !t.IsGenericType )
        {
            // Any other non-generic delegate (rare, but possible via inheritance).
            return typeof( System.MulticastDelegate ).IsAssignableFrom( t )
                   && t != typeof( System.MulticastDelegate )
                   && t != typeof( System.Delegate );
        }

        // Generic delegates: Func<TResult>, Func<T,TResult>, ..., Action<T>, Action<T,T2>, ...
        var def  = t.GetGenericTypeDefinition();
        var name = def.Name; // "Func`1", "Action`2", etc.
        return name.StartsWith( "Func`", StringComparison.Ordinal )
            || name.StartsWith( "Action`", StringComparison.Ordinal );
    }


    private static readonly HashSet<string> PayloadContentPropNames = new( StringComparer.Ordinal )
    {
        "Content", "Placeholder", "CheckboxSize", "Source", "IconName",
    };

    private static HashSet<string> PayloadContentNamesForKind( ControlType kind )
    {
        return kind switch
        {
            ControlType.Label          => new HashSet<string>( StringComparer.Ordinal ) { "Content" },
            ControlType.Image          => new HashSet<string>( StringComparer.Ordinal ) { "Source" },
            ControlType.IconPanel      => new HashSet<string>( StringComparer.Ordinal ) { "IconName" },
            ControlType.Button         => new HashSet<string>( StringComparer.Ordinal ) { "Content" },
            ControlType.Checkbox       => new HashSet<string>( StringComparer.Ordinal ) { "Content", "CheckboxSize" },
            ControlType.TextEntry      => new HashSet<string>( StringComparer.Ordinal ) { "Placeholder" },

            // Panel, SplitContainer, ButtonGroup, DropDown, Form, Field, FieldControl — none.
            ControlType.Panel or ControlType.SplitContainer or
            ControlType.ButtonGroup or ControlType.DropDown or
            ControlType.Form or ControlType.Field or ControlType.FieldControl
                => new HashSet<string>( StringComparer.Ordinal ),

            _ => new HashSet<string>( StringComparer.Ordinal ),
        };
    }

    private static List<ContractField> WalkRecordGroups( Type payloadType, ControlType kind )
    {
        var results   = new List<ContractField>();
        var seenNames = new HashSet<string>( StringComparer.Ordinal );

        // Determine which payload-content names this kind actually uses.
        var kindPayloadContentNames = PayloadContentNamesForKind( kind );

        CollectPropsWithGroups( payloadType, results, seenNames, kindPayloadContentNames );

        CollectPropsWithGroups( typeof( Payload ), results, seenNames, kindPayloadContentNames );

        CollectPropsWithGroups( typeof( ControlRecord ), results, seenNames, kindPayloadContentNames );

        var gateGroups = new HashSet<string>(
            results
                .Where( f => f.IsOverrideGate )
                .Select( f => f.Group ),
            StringComparer.Ordinal );

        for ( int i = 0; i < results.Count; i++ )
        {
            var f = results[i];
            if ( f.IsOverrideGate || string.IsNullOrEmpty( f.Group ) ) continue;
            if ( gateGroups.Contains( f.Group ) )
                results[i] = f with { GatedByGroup = f.Group };
        }

        return results;
    }

    private static void CollectPropsWithGroups(
        Type sourceType,
        List<ContractField> results,
        HashSet<string> seenNames,
        HashSet<string> kindPayloadContentNames )
    {
        foreach ( var prop in sourceType.GetProperties(
            BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly ) )
        {
            // [Hide] properties: skip (they're internal/non-inspector).
            if ( prop.GetCustomAttribute<HideAttribute>() is not null ) continue;

            if ( PayloadContentPropNames.Contains( prop.Name )
                 && !kindPayloadContentNames.Contains( prop.Name ) ) continue;

            // Collect the [Group] attribute (Sandbox.GroupAttribute).
            var groupAttr = prop.GetCustomAttribute<GroupAttribute>();
            var group     = groupAttr?.Value ?? "";

            var isOverrideGate = prop.Name.StartsWith( "Override", StringComparison.Ordinal )
                && prop.PropertyType == typeof( bool );

            if ( string.IsNullOrEmpty( group ) && !isOverrideGate ) continue;

            if ( !seenNames.Add( prop.Name ) ) continue;

            results.Add( new ContractField(
                Name:          prop.Name,
                ClrType:       prop.PropertyType,
                Group:         group,
                IsOverrideGate: isOverrideGate,
                GatedByGroup:  "" /* filled in after the full list is built */ ) );
        }
    }


    private static List<ContractField> MergeFields(
        List<ContractField> engineFields,
        List<ContractField> recordFields )
    {
        var result   = new List<ContractField>( recordFields );
        var nameSet  = new HashSet<string>(
            recordFields.Select( f => f.Name ), StringComparer.Ordinal );

        // Add engine fields not already covered by the record side.
        foreach ( var ef in engineFields )
        {
            if ( nameSet.Add( ef.Name ) )
                result.Add( ef );
        }

        return result;
    }


    private static string ResolveInspectorIcon( Type engineType, ControlType kind )
    {
        // Try the engine type's [Icon] attribute first (may not be present).
        if ( engineType is not null )
        {
            var typeDesc = EditorTypeLibrary.GetType( engineType );
            if ( typeDesc is not null )
            {
                var iconAttr = typeDesc.GetAttribute<IconAttribute>();
                if ( iconAttr is not null && !string.IsNullOrEmpty( iconAttr.Value ) )
                {
                    Log.Info( $"{LogPrefix} ContractScanner.ResolveInspectorIcon({kind}): found [Icon] = '{iconAttr.Value}'" );
                    return iconAttr.Value;
                }
            }
        }

        // Fall back to the _seed map (v1 default; was ControlMetadata.IconName).
        var icon = _seed[kind].Icon;
        Log.Info( $"{LogPrefix} ContractScanner.ResolveInspectorIcon({kind}): using seed fallback = '{icon}'" );
        return icon;
    }


    private static readonly JsonSerializerOptions OverlayJsonOptions = new()
    {
        PropertyNameCaseInsensitive = true,
        Converters = { new JsonStringEnumConverter() },
    };

    private static void ApplyOverlay( Dictionary<ControlType, ControlContract> built )
    {
        var path = ResolveOverlayPath();
        if ( !File.Exists( path ) )
        {
            var msg = $"{LogPrefix} ContractScanner.ApplyOverlay: contract-overlay.json not found at '{path}'. " +
                      $"This file is a required addon asset — ensure it exists in Editor/Contracts/.";
            Log.Error( msg );
            throw new FileNotFoundException( msg, path );
        }

        Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: loading overlay from '{path}'" );
        string text;
        try
        {
            text = File.ReadAllText( path );
        }
        catch ( IOException ex )
        {
            var msg = $"{LogPrefix} ContractScanner.ApplyOverlay: failed to read '{path}': {ex.Message}";
            Log.Error( msg );
            throw;
        }

        OverlayDocument doc;
        try
        {
            doc = JsonSerializer.Deserialize<OverlayDocument>( text, OverlayJsonOptions );
        }
        catch ( JsonException ex )
        {
            var msg = $"{LogPrefix} ContractScanner.ApplyOverlay: JSON parse error in '{path}': {ex.Message}";
            Log.Error( msg );
            throw;
        }

        if ( doc is null || doc.Entries is null || doc.Entries.Count == 0 )
        {
            Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: overlay loaded (version={doc?.Version}) — no entries, nothing to merge." );
            return;
        }

        Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: overlay version={doc.Version}, {doc.Entries.Count} entries to apply." );

        var applied = 0;
        foreach ( var entry in doc.Entries )
        {
            // Governance tripwire: every entry MUST carry a non-empty _cite.
            if ( string.IsNullOrWhiteSpace( entry.Cite ) )
            {
                var msg = $"{LogPrefix} ContractScanner.ApplyOverlay: contract-overlay.json entry for " +
                          $"'{entry.Kind}' is missing a non-empty \"_cite\" — every overlay entry must cite " +
                          $"the engine type / method / behaviour it encodes. Remove the entry or add a _cite.";
                Log.Error( msg );
                throw new InvalidDataException( msg );
            }

            if ( !built.TryGetValue( entry.Kind, out var existing ) )
            {
                Log.Warning( $"{LogPrefix} ContractScanner.ApplyOverlay: overlay entry kind '{entry.Kind}' " +
                             $"is not in the built contract table (unrecognised ControlType?). Skipping." );
                continue;
            }

            // Merge each optional field — null means "leave the reflected value unchanged".
            var slots         = entry.Slots ?? existing.Slots;
            var payloadFields = entry.PayloadFields is null
                ? existing.PayloadFields
                : MergePayloadFields( existing.PayloadFields, entry.PayloadFields );
            var libraryTag    = entry.LibraryTag    ?? existing.LibraryTag;
            var inspectorIcon = entry.InspectorIcon ?? existing.InspectorIcon;
            var isContainer   = entry.IsContainer   ?? existing.IsContainer;
            var preview       = entry.PreviewStrategy ?? existing.PreviewStrategy;

            built[entry.Kind] = existing with
            {
                Slots           = new ReadOnlyCollection<SlotDefinition>( slots.ToList() ),
                PayloadFields   = payloadFields,
                LibraryTag      = libraryTag,
                InspectorIcon   = inspectorIcon,
                IsContainer     = isContainer,
                PreviewStrategy = preview,
            };

            Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: merged '{entry.Kind}' — " +
                      $"slots={slots.Count} payloadFields={payloadFields.Count} " +
                      $"tag='{libraryTag}' icon='{inspectorIcon}' container={isContainer} preview={preview}" );
            applied++;
        }

        Log.Info( $"{LogPrefix} ContractScanner.ApplyOverlay: {applied}/{doc.Entries.Count} entries applied." );
    }

    private static IReadOnlyList<ContractField> MergePayloadFields(
        IReadOnlyList<ContractField> existing,
        IReadOnlyList<OverlayField> overlayFields )
    {
        // Index overlay fields by name for O(1) lookup.
        var overlayByName = new Dictionary<string, OverlayField>( StringComparer.Ordinal );
        foreach ( var overlayField in overlayFields )
        {
            if ( !string.IsNullOrEmpty( overlayField.Name ) )
                overlayByName[overlayField.Name] = overlayField;
        }

        var result    = new List<ContractField>();
        var seenNames = new HashSet<string>( StringComparer.Ordinal );

        // Pass 1: walk existing (reflected) fields; apply overlay overrides where present.
        foreach ( var reflectedField in existing )
        {
            seenNames.Add( reflectedField.Name );
            if ( overlayByName.TryGetValue( reflectedField.Name, out var overlayOverride ) )
            {
                // Overlay override: keep reflected CLR type; take overlay group/gate data.
                result.Add( reflectedField with
                {
                    Group          = string.IsNullOrEmpty( overlayOverride.Group ) ? reflectedField.Group : overlayOverride.Group,
                    IsOverrideGate = overlayOverride.IsOverrideGate,
                    GatedByGroup   = string.IsNullOrEmpty( overlayOverride.GatedByGroup ) ? reflectedField.GatedByGroup : overlayOverride.GatedByGroup,
                } );
            }
            else
            {
                // No overlay entry for this field — keep reflected as-is.
                result.Add( reflectedField );
            }
        }

        // Pass 2: add overlay-only fields (not seen in reflection).
        foreach ( var addField in overlayFields )
        {
            if ( string.IsNullOrEmpty( addField.Name ) ) continue;
            if ( !seenNames.Add( addField.Name ) ) continue; // already processed above

            // Resolve CLR type from the name string; fall back to string when unknown.
            var clrType = ResolveClrType( addField.ClrTypeName );

            result.Add( new ContractField(
                Name:           addField.Name,
                ClrType:        clrType,
                Group:          addField.Group,
                IsOverrideGate: addField.IsOverrideGate,
                GatedByGroup:   addField.GatedByGroup ) );
        }

        return new ReadOnlyCollection<ContractField>( result );
    }

    private static Type ResolveClrType( string typeName )
    {
        if ( string.IsNullOrEmpty( typeName ) ) return typeof( string );
        return typeName switch
        {
            "String"  or "string"  => typeof( string ),
            "Boolean" or "bool"    => typeof( bool ),
            "Int32"   or "int"     => typeof( int ),
            "Single"  or "float"   => typeof( float ),
            "Double"  or "double"  => typeof( double ),
            _ => typeof( string ), // safe fallback — overlay-only fields are informational
        };
    }

    private static readonly (string Ident, string Subpath)[] OverlayFileRoots = new[]
    {
        ( "razordesigner",        "Editor/Contracts/contract-overlay.json" ),
        ( "grains_razordesigner", "Editor/Contracts/contract-overlay.json" ),
        // Also check directly inside the dev-workspace library subdirectory.
        ( "grains_razordesigner", "Libraries/xaz.razordesigner/Editor/Contracts/contract-overlay.json" ),
    };

    private static string ResolveOverlayPath()
    {
        foreach ( var (ident, subpath) in OverlayFileRoots )
        {
            var root = System.Linq.Enumerable
                .FirstOrDefault( EditorUtility.Projects.GetAll(),
                    p => string.Equals( p.Config?.Ident, ident, StringComparison.OrdinalIgnoreCase ) )
                ?.GetRootPath();
            if ( string.IsNullOrEmpty( root ) ) continue;
            var candidate = Path.Combine( root, subpath );
            if ( File.Exists( candidate ) )
            {
                Log.Info( $"{LogPrefix} ContractScanner.ResolveOverlayPath: resolved via ident='{ident}' → '{candidate}'" );
                return candidate;
            }
        }

        var assemblyDir = Path.GetDirectoryName( typeof( ContractScanner ).Assembly.Location ) ?? "";
        var lastResort  = Path.Combine( assemblyDir, "contract-overlay.json" );
        Log.Warning( $"{LogPrefix} ContractScanner.ResolveOverlayPath: project-list lookup found nothing — " +
                     $"falling back to assembly-relative path '{lastResort}'. " +
                     $"This likely means EditorUtility.Projects.GetAll() returned no projects matching " +
                     $"'razordesigner' or 'grains_razordesigner'." );
        return lastResort;
    }


    private sealed class ContractTable : IContractTable
    {
        private readonly ReadOnlyDictionary<ControlType, ControlContract> _data;

        public ContractTable( ReadOnlyDictionary<ControlType, ControlContract> data )
        {
            _data = data;
        }

        public ControlContract Get( ControlType kind )
        {
            if ( _data.TryGetValue( kind, out var contract ) )
                return contract;
            throw new KeyNotFoundException(
                $"ContractTable.Get: no contract registered for ControlType.{kind}. " +
                $"This should never happen if all 13 types were built successfully." );
        }

        public IEnumerable<ControlContract> All => _data.Values;
    }
}