using Microsoft.Build.Locator;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.MSBuild;

namespace ClosureAnalyzer;

public static class Program
{
    // Roots — see POSTMORTEM-2026-06-28-pass3-attempt.md §"What the right tool looks like".
    // Fully-qualified metadata names so GetTypeByMetadataName can find them directly.
    private static readonly string[] HardRoots =
    {
        "SVSim.BattleNode.Sessions.Engine.SessionBattleEngine",
        "SVSim.BattleEngine.Rng.HeadlessBattleMgr",
        "SVSim.BattleEngine.Rng.HeadlessNetworkBattleMgr",
        "Wizard.Battle.Mulligan.MulliganMgrBase",
    };

    private static readonly HashSet<string> NUnitAttributeNames = new(StringComparer.Ordinal)
    {
        "TestAttribute", "TestCaseAttribute", "TestCaseSourceAttribute",
        "TestFixtureAttribute", "TestFixtureSourceAttribute", "TheoryAttribute",
        "OneTimeSetUpAttribute", "OneTimeTearDownAttribute", "SetUpAttribute",
        "TearDownAttribute", "SetUpFixtureAttribute",
    };

    private static readonly HashSet<string> TrackedAssemblies = new(StringComparer.Ordinal)
    {
        "SVSim.BattleEngine", "SVSim.BattleNode", "SVSim.BattleEngine.Tests",
    };

    // Only types from THIS assembly are deletion candidates. Everything else is either
    // a downstream consumer (BattleNode/Tests) or external (System/NuGet).
    private const string CandidateAssembly = "SVSim.BattleEngine";

    public static async Task<int> Main(string[] args)
    {
        MSBuildLocator.RegisterDefaults();

        bool trimMode = args.Contains("--trim");
        var positional = args.Where(a => !a.StartsWith("--")).ToArray();

        var repoRoot = FindRepoRoot();
        var slnPath = Path.Combine(repoRoot, "DCGEngine.sln");
        var outputPath = positional.Length > 0
            ? positional[0]
            : Path.Combine(repoRoot, "..", "..", "data_dumps", "reports", "engine-cleanup", "provably-unreachable.tsv");
        outputPath = Path.GetFullPath(outputPath);

        if (trimMode)
            Console.Error.WriteLine("[analyzer] TRIM MODE: will rewrite Shim/Generated/*.g.cs after analysis");

        Console.Error.WriteLine($"[analyzer] solution: {slnPath}");
        Console.Error.WriteLine($"[analyzer] output:   {outputPath}");

        using var workspace = MSBuildWorkspace.Create();
        workspace.WorkspaceFailed += (_, e) =>
        {
            if (e.Diagnostic.Kind == WorkspaceDiagnosticKind.Failure)
                Console.Error.WriteLine($"[workspace] FAIL: {e.Diagnostic.Message}");
        };

        var solution = await workspace.OpenSolutionAsync(slnPath);
        var engineProject = solution.Projects.First(p => p.Name == "SVSim.BattleEngine");
        var nodeProject = solution.Projects.First(p => p.Name == "SVSim.BattleNode");
        var testsProject = solution.Projects.First(p => p.Name == "SVSim.BattleEngine.Tests");

        Console.Error.WriteLine("[analyzer] compiling SVSim.BattleEngine...");
        var engineComp = await engineProject.GetCompilationAsync()
            ?? throw new InvalidOperationException("engine compilation null");
        Console.Error.WriteLine("[analyzer] compiling SVSim.BattleNode...");
        var nodeComp = await nodeProject.GetCompilationAsync()
            ?? throw new InvalidOperationException("node compilation null");
        Console.Error.WriteLine("[analyzer] compiling SVSim.BattleEngine.Tests...");
        var testsComp = await testsProject.GetCompilationAsync()
            ?? throw new InvalidOperationException("tests compilation null");

        // MSBuildWorkspace sometimes loads BattleNode without the extern-alias project
        // reference to BattleEngine (an upstream NuGet audit warning during restore can
        // cause it to bail out before honoring ProjectReference Aliases — guarded against
        // by NU1903 suppression in those csprojs, but defensive in case other warnings
        // crop up). Detect a missing cross-project lookup and patch the compilation by
        // hand so semantic queries through `engine::` resolve correctly.
        if (nodeComp.GetTypeByMetadataName("NetworkUserInfoData") is null)
        {
            var engineRef = engineComp.ToMetadataReference(
                aliases: System.Collections.Immutable.ImmutableArray.Create("engine"));
            nodeComp = nodeComp.AddReferences(engineRef);
            Console.Error.WriteLine("[fixup] nodeComp missing engine reference — re-added with alias=engine");
        }
        if (testsComp.GetTypeByMetadataName("NetworkUserInfoData") is null)
        {
            var engineRef = engineComp.ToMetadataReference(
                aliases: System.Collections.Immutable.ImmutableArray.Create("global", "engine"));
            testsComp = testsComp.AddReferences(engineRef);
            Console.Error.WriteLine("[fixup] testsComp missing engine reference — re-added with aliases=global,engine");
        }

        var compsByTree = new Dictionary<SyntaxTree, Compilation>();
        foreach (var t in engineComp.SyntaxTrees) compsByTree[t] = engineComp;
        foreach (var t in nodeComp.SyntaxTrees) compsByTree[t] = nodeComp;
        foreach (var t in testsComp.SyntaxTrees) compsByTree[t] = testsComp;

        // Collect roots.
        var roots = new List<INamedTypeSymbol>();
        foreach (var name in HardRoots)
        {
            var sym =
                engineComp.GetTypeByMetadataName(name) ??
                nodeComp.GetTypeByMetadataName(name) ??
                testsComp.GetTypeByMetadataName(name);
            if (sym is null)
                throw new InvalidOperationException($"root not found in any tracked compilation: {name}");
            roots.Add(sym);
            Console.Error.WriteLine($"[root] {name}");
        }

        int testRoots = 0;
        foreach (var t in EnumerateTypes(testsComp.GlobalNamespace))
        {
            if (HasNUnitAttribute(t))
            {
                roots.Add(t);
                testRoots++;
            }
        }
        Console.Error.WriteLine($"[root] NUnit-rooted test types: {testRoots}");

        // Every type defined in SVSim.BattleNode is a root. BattleNode is the consumer of
        // BattleEngine; its types are called from the ASP.NET host / DI container / hub
        // entrypoints that this static walk can't see. Walking them all is cheap (BattleNode
        // is ~110 types) and ensures every BattleEngine API call site is detected.
        int nodeRoots = 0;
        foreach (var t in EnumerateTypes(nodeComp.GlobalNamespace))
        {
            if (t.ContainingAssembly?.Name == "SVSim.BattleNode")
            {
                roots.Add(t);
                nodeRoots++;
            }
        }
        Console.Error.WriteLine($"[root] BattleNode types: {nodeRoots}");

        // BFS the closure. `seen` is keyed by fully-qualified type name (string) for the
        // same reason reachedMembers is — cross-compilation lookups via the engine alias
        // produce INamedTypeSymbols whose SymbolEqualityComparer-identity doesn't match
        // engineComp's direct view (so NullDetailPanelControl, reached through
        // SessionBattleEngine's `using NullDetailPanelControl = engine::NullDetailPanelControl`,
        // wasn't recognized as reachable in engineComp's later enumeration pass).
        var queue = new Queue<INamedTypeSymbol>();
        var seen = new HashSet<string>(StringComparer.Ordinal);
        var seenTypeSymbols = new List<INamedTypeSymbol>(); // for later iteration (override/iface propagation)
        string TypeKey(INamedTypeSymbol t) =>
            t.OriginalDefinition.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
        // Method-level: every IMethodSymbol / IPropertySymbol / IFieldSymbol / IEventSymbol
        // referenced by reached code. Tracked by SignatureKey rather than ISymbol identity
        // because cross-compilation symbol resolution (BattleNode resolving a BattleEngine
        // method via an extern-alias project reference) produces an IMethodSymbol whose
        // SymbolEqualityComparer-identity does NOT match engineComp's direct view of the
        // same method, even though they refer to the same assembly. Display-string keys
        // dodge that entirely.
        var reachedMembers = new HashSet<string>(StringComparer.Ordinal);
        // String literals seen in reachable code — used as a defensive fallback for
        // reflection-driven dispatch (typeof(T).GetMethod("X"), Activator.CreateInstance,
        // SendMessage by name, etc.). Any member of any reachable type whose Name appears
        // here is kept alive even if the static walk says dead.
        var literalStrings = new HashSet<string>(StringComparer.Ordinal);
        foreach (var r in roots) Enqueue(r, queue);

        long popped = 0;
        while (queue.Count > 0)
        {
            var t = queue.Dequeue();
            var orig = (INamedTypeSymbol)t.OriginalDefinition;
            if (!seen.Add(TypeKey(orig))) continue;
            seenTypeSymbols.Add(orig);

            popped++;
            if (popped % 200 == 0)
                Console.Error.WriteLine($"[bfs] popped={popped} queue={queue.Count} seen={seen.Count}");

            var asmName = orig.ContainingAssembly?.Name;
            if (asmName is null || !TrackedAssemblies.Contains(asmName))
                continue;

            // Base + interfaces + containing.
            if (orig.BaseType is not null) Enqueue(orig.BaseType, queue);
            foreach (var iface in orig.Interfaces) Enqueue(iface, queue);
            if (orig.ContainingType is not null) Enqueue(orig.ContainingType, queue);

            // Type's own attributes.
            foreach (var attr in orig.GetAttributes())
                if (attr.AttributeClass is not null)
                    Enqueue(attr.AttributeClass, queue);

            // Generic constraints on the type itself.
            foreach (var tp in orig.TypeParameters)
                foreach (var con in tp.ConstraintTypes)
                    EnqueueAny(con, queue);

            // Members.
            foreach (var member in orig.GetMembers())
            {
                foreach (var attr in member.GetAttributes())
                    if (attr.AttributeClass is not null)
                        Enqueue(attr.AttributeClass, queue);

                switch (member)
                {
                    case IFieldSymbol f:
                        EnqueueAny(f.Type, queue);
                        break;
                    case IPropertySymbol p:
                        EnqueueAny(p.Type, queue);
                        foreach (var par in p.Parameters)
                        {
                            EnqueueAny(par.Type, queue);
                            foreach (var pa in par.GetAttributes())
                                if (pa.AttributeClass is not null) Enqueue(pa.AttributeClass, queue);
                        }
                        break;
                    case IEventSymbol e:
                        EnqueueAny(e.Type, queue);
                        break;
                    case IMethodSymbol m:
                        EnqueueAny(m.ReturnType, queue);
                        foreach (var par in m.Parameters)
                        {
                            EnqueueAny(par.Type, queue);
                            foreach (var pa in par.GetAttributes())
                                if (pa.AttributeClass is not null) Enqueue(pa.AttributeClass, queue);
                        }
                        foreach (var tp in m.TypeParameters)
                            foreach (var con in tp.ConstraintTypes)
                                EnqueueAny(con, queue);
                        foreach (var iimpl in m.ExplicitInterfaceImplementations)
                            Enqueue(iimpl.ContainingType, queue);
                        break;
                    case INamedTypeSymbol nested:
                        Enqueue(nested, queue);
                        break;
                }
            }

            // Walk syntax of every declaration to catch method-body / initializer / typeof refs.
            foreach (var sref in orig.DeclaringSyntaxReferences)
            {
                var node = sref.GetSyntax();
                var tree = node.SyntaxTree;
                if (!compsByTree.TryGetValue(tree, out var comp)) continue;
                var model = comp.GetSemanticModel(tree);

                // File-scope using directives (`using Foo = engine::Foo;`) live on the
                // CompilationUnit, not inside the type. Walk them so types referenced only
                // via aliased usings get into the closure. Without this, the cascade
                // deletes types like NullDetailPanelControl that SessionBattleEngine
                // references purely through the alias.
                //
                // GetTypeInfo on the name-syntax inside a using directive returns void
                // because it's not an expression context — go through GetAliasInfo
                // (for `using X = T;`) and GetSymbolInfo (for plain `using NS;`) instead.
                if (tree.GetRoot() is Microsoft.CodeAnalysis.CSharp.Syntax.CompilationUnitSyntax cu)
                {
                    foreach (var usingDir in cu.Usings)
                    {
                        // Alias form: `using X = Y;` — GetDeclaredSymbol on the directive
                        // resolves to an IAliasSymbol whose Target is the real type.
                        if (usingDir.Alias is not null)
                        {
                            if (model.GetDeclaredSymbol(usingDir) is IAliasSymbol alias)
                            {
                                AddSymbol(alias.Target, queue);
                                RecordMember(alias.Target, reachedMembers);
                            }
                        }
                        // Plain form: `using NS;` or `using static T;` — resolve the
                        // Name itself.
                        var nameSymbol = model.GetSymbolInfo(usingDir.Name!).Symbol;
                        AddSymbol(nameSymbol, queue);
                        RecordMember(nameSymbol, reachedMembers);
                        // (No longer: enqueue every type in the namespace.) That rule was
                        // a workaround for dangling `using` directives after every type
                        // in a namespace got trimmed. With the engine reduced to its
                        // converged state, keeping types alive just because a file
                        // imports their namespace is wrong — the third-party SDK shims
                        // (Facebook.Unity, Steamworks, ZXing, BestHTTP, etc.) only stay
                        // because of stale `using` directives in engine files that never
                        // actually reference any type from them. The new dangling-using
                        // sweep at the end of trim handles the cleanup automatically.
                    }
                }

                foreach (var descendant in node.DescendantNodes())
                {
                    var ti = model.GetTypeInfo(descendant);
                    EnqueueAny(ti.Type, queue);
                    EnqueueAny(ti.ConvertedType, queue);

                    var si = model.GetSymbolInfo(descendant);
                    AddSymbol(si.Symbol, queue);
                    RecordMember(si.Symbol, reachedMembers);
                    foreach (var cand in si.CandidateSymbols)
                    {
                        AddSymbol(cand, queue);
                        RecordMember(cand, reachedMembers);
                    }

                    // String literals for reflection-target fallback.
                    if (descendant is Microsoft.CodeAnalysis.CSharp.Syntax.LiteralExpressionSyntax lit
                        && lit.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.StringLiteralExpression))
                    {
                        var s = lit.Token.ValueText;
                        if (!string.IsNullOrEmpty(s) && s.Length < 200)
                            literalStrings.Add(s);
                    }
                }
            }
        }

        Console.Error.WriteLine($"[bfs] closure size: {seen.Count}");
        Console.Error.WriteLine($"[bfs] string literals seen: {literalStrings.Count}");
        Console.Error.WriteLine($"[bfs] initial reached members: {reachedMembers.Count}");

        // Propagate virtual / interface dispatch within the reachable type set.
        // If a method M is reached and any subclass override M' lives on a reachable
        // type, M' is also reached. Same for interface impls. Iterate until fixpoint.
        var reachedTypeList = seenTypeSymbols
            .Where(t => t.ContainingAssembly is not null && TrackedAssemblies.Contains(t.ContainingAssembly.Name))
            .ToList();

        // Unconditional protection pass — required for COMPILATION, not just reachability:
        //   1. Any override of an abstract method must stay.
        //   2. Any implementation of an interface member must stay if the interface is in the
        //      closure.
        //   3. Any non-abstract override is conservatively kept — the override's body might
        //      differ from the base (and confirming equality across the whole vendored
        //      decompile is not feasible). Cheap to keep, expensive to get wrong.
        //   4. Every member of a reachable INTERFACE stays. Explicit interface impls
        //      reference these members by their fully-qualified name; deleting an interface
        //      member while its explicit impl survives produces "is not found among members
        //      of the interface" (CS0539).
        foreach (var t in reachedTypeList)
        {
            if (t.TypeKind == TypeKind.Interface)
            {
                foreach (var member in t.GetMembers())
                {
                    reachedMembers.Add(SignatureKey(member));
                    if (member is IPropertySymbol ip)
                    {
                        if (ip.GetMethod is not null) reachedMembers.Add(SignatureKey(ip.GetMethod));
                        if (ip.SetMethod is not null) reachedMembers.Add(SignatureKey(ip.SetMethod));
                    }
                    if (member is IEventSymbol ie)
                    {
                        if (ie.AddMethod is not null) reachedMembers.Add(SignatureKey(ie.AddMethod));
                        if (ie.RemoveMethod is not null) reachedMembers.Add(SignatureKey(ie.RemoveMethod));
                    }
                }
            }
        }
        foreach (var t in reachedTypeList)
        {
            foreach (var member in t.GetMembers())
            {
                // Constructors of reachable types always stay. A derived ctor that's removed
                // breaks compilation when the base type lacks a parameterless ctor (CS7036),
                // and detecting that gates on chasing the base chain — far cheaper to keep
                // every ctor than to compute when it's safe to drop.
                //
                // Static constructors are invoked by the runtime on first access; static
                // analysis sees no caller and would mark them dead, but their bodies often
                // populate static fields (`_str2Keyword`, etc.) that other reachable code
                // reads — removing them produces NullReferenceException at runtime.
                if (member is IMethodSymbol mc &&
                    (mc.MethodKind == MethodKind.Constructor || mc.MethodKind == MethodKind.StaticConstructor))
                {
                    reachedMembers.Add(SignatureKey(mc));
                }
                if (member is IMethodSymbol m && m.IsOverride)
                {
                    reachedMembers.Add(SignatureKey(m));
                }
                if (member is IPropertySymbol p && p.IsOverride)
                {
                    reachedMembers.Add(SignatureKey(p));
                    if (p.GetMethod is not null) reachedMembers.Add(SignatureKey(p.GetMethod));
                    if (p.SetMethod is not null) reachedMembers.Add(SignatureKey(p.SetMethod));
                }
                if (member is IEventSymbol e && e.IsOverride)
                {
                    reachedMembers.Add(SignatureKey(e));
                }
                // User-defined operators come in mandatory pairs (==/!=, </>, <=/>=) per
                // CS0216. Keeping one without the other is a build error. Cheap to keep
                // all of them.
                if (member is IMethodSymbol mop &&
                    (mop.MethodKind == MethodKind.UserDefinedOperator || mop.MethodKind == MethodKind.Conversion))
                {
                    reachedMembers.Add(SignatureKey(mop));
                }
                // Explicit interface implementations always stay. Belt-and-suspenders on
                // top of the AllInterfaces walk below — FindImplementationForInterfaceMember
                // can miss in corner cases (e.g. IList vs ICollection both declaring Clear
                // with overlapping resolution paths).
                if (member is IMethodSymbol mex && mex.ExplicitInterfaceImplementations.Length > 0)
                {
                    reachedMembers.Add(SignatureKey(mex));
                }
                if (member is IPropertySymbol pex && pex.ExplicitInterfaceImplementations.Length > 0)
                {
                    reachedMembers.Add(SignatureKey(pex));
                    if (pex.GetMethod is not null) reachedMembers.Add(SignatureKey(pex.GetMethod));
                    if (pex.SetMethod is not null) reachedMembers.Add(SignatureKey(pex.SetMethod));
                }
                if (member is IEventSymbol eex && eex.ExplicitInterfaceImplementations.Length > 0)
                {
                    reachedMembers.Add(SignatureKey(eex));
                }
            }

            foreach (var iface in t.AllInterfaces)
            {
                // EVERY implemented interface — including external (System.Collections.ICollection,
                // IComparer<T>, etc.) — needs its impls kept. The interface contract must be
                // satisfied for compilation; CS0535 doesn't care whether the interface is in our
                // assemblies or in mscorlib.
                foreach (var ifaceM in iface.GetMembers())
                {
                    var impl = t.FindImplementationForInterfaceMember(ifaceM);
                    if (impl is null) continue;
                    reachedMembers.Add(SignatureKey(impl));
                    if (impl is IPropertySymbol ip)
                    {
                        if (ip.GetMethod is not null) reachedMembers.Add(SignatureKey(ip.GetMethod));
                        if (ip.SetMethod is not null) reachedMembers.Add(SignatureKey(ip.SetMethod));
                    }
                    if (impl is IEventSymbol ie)
                    {
                        if (ie.AddMethod is not null) reachedMembers.Add(SignatureKey(ie.AddMethod));
                        if (ie.RemoveMethod is not null) reachedMembers.Add(SignatureKey(ie.RemoveMethod));
                    }
                }
            }
        }
        Console.Error.WriteLine($"[propagate] members after unconditional override+impl pass: {reachedMembers.Count}");

        // Chase override → overridden base. Required for compilation: a kept override
        // needs its base method to still exist (CS0115).
        bool basesChanged = true;
        while (basesChanged)
        {
            basesChanged = false;
            foreach (var t in reachedTypeList)
            {
                foreach (var member in t.GetMembers())
                {
                    if (member is IMethodSymbol m && m.IsOverride && reachedMembers.Contains(SignatureKey(m)))
                    {
                        var b = m.OverriddenMethod;
                        while (b is not null)
                        {
                            if (!reachedMembers.Add(SignatureKey(b))) break;
                            basesChanged = true;
                            b = b.OverriddenMethod;
                        }
                    }
                    if (member is IPropertySymbol p && p.IsOverride && reachedMembers.Contains(SignatureKey(p)))
                    {
                        var b = p.OverriddenProperty;
                        while (b is not null)
                        {
                            if (!reachedMembers.Add(SignatureKey(b))) break;
                            if (b.GetMethod is not null) reachedMembers.Add(SignatureKey(b.GetMethod));
                            if (b.SetMethod is not null) reachedMembers.Add(SignatureKey(b.SetMethod));
                            basesChanged = true;
                            b = b.OverriddenProperty;
                        }
                    }
                }
            }
        }
        Console.Error.WriteLine($"[propagate] members after override-base chase: {reachedMembers.Count}");

        // Build override map once: derivedMethod -> overriddenMethod (all walked).
        // Also build interface-impl map: implMethod -> interface method.
        bool changed = true;
        int rounds = 0;
        while (changed)
        {
            changed = false;
            rounds++;
            foreach (var t in reachedTypeList)
            {
                foreach (var m in t.GetMembers().OfType<IMethodSymbol>())
                {
                    var key = SignatureKey(m);
                    if (reachedMembers.Contains(key)) continue;

                    // Override of a reached base method.
                    var ov = m.OverriddenMethod;
                    while (ov is not null)
                    {
                        if (reachedMembers.Contains(SignatureKey(ov)))
                        {
                            reachedMembers.Add(key);
                            changed = true;
                            break;
                        }
                        ov = ov.OverriddenMethod;
                    }
                    if (reachedMembers.Contains(key)) continue;

                    // Interface impl: is m the impl of some reached interface method?
                    foreach (var iface in t.AllInterfaces)
                    {
                        bool hit = false;
                        foreach (var ifaceM in iface.GetMembers().OfType<IMethodSymbol>())
                        {
                            if (!reachedMembers.Contains(SignatureKey(ifaceM))) continue;
                            var impl = t.FindImplementationForInterfaceMember(ifaceM);
                            if (impl is IMethodSymbol implM &&
                                SymbolEqualityComparer.Default.Equals(implM.OriginalDefinition, m.OriginalDefinition))
                            {
                                reachedMembers.Add(key);
                                changed = true;
                                hit = true;
                                break;
                            }
                        }
                        if (hit) break;
                    }
                }

                // Property/event/field accessors come with the property. If the property
                // is reached, its accessors are reached too.
                foreach (var p in t.GetMembers().OfType<IPropertySymbol>())
                {
                    if (!reachedMembers.Contains(SignatureKey(p))) continue;
                    if (p.GetMethod is not null) reachedMembers.Add(SignatureKey(p.GetMethod));
                    if (p.SetMethod is not null) reachedMembers.Add(SignatureKey(p.SetMethod));
                }
                foreach (var ev in t.GetMembers().OfType<IEventSymbol>())
                {
                    if (!reachedMembers.Contains(SignatureKey(ev))) continue;
                    if (ev.AddMethod is not null) reachedMembers.Add(SignatureKey(ev.AddMethod));
                    if (ev.RemoveMethod is not null) reachedMembers.Add(SignatureKey(ev.RemoveMethod));
                }
            }
        }
        Console.Error.WriteLine($"[propagate] members after virtual/iface ({rounds} rounds): {reachedMembers.Count}");

        // Cross-check against the runtime live-methods baseline. Any method named in the
        // baseline must stay alive even if static analysis says dead — Harmony captures
        // runtime patches we can't see statically.
        var baselinePath = Path.Combine(
            Path.GetDirectoryName(outputPath)!, "live-methods.baseline.txt");
        var baseline = new HashSet<string>(StringComparer.Ordinal);
        if (File.Exists(baselinePath))
        {
            foreach (var line in File.ReadAllLines(baselinePath))
            {
                var s = line.Trim();
                if (s.Length > 0) baseline.Add(s);
            }
            Console.Error.WriteLine($"[baseline] loaded {baseline.Count} live-method names from {baselinePath}");
        }
        else
        {
            Console.Error.WriteLine($"[baseline] WARNING: not found at {baselinePath} — skipping cross-check");
        }

        // Emit dead-members.tsv covering every declared method/property/field/event in
        // SVSim.BattleEngine that's in a reachable type but NOT referenced.
        var deadMembersPath = Path.Combine(Path.GetDirectoryName(outputPath)!, "dead-members.tsv");
        var deadByFile = new Dictionary<string, List<DeadMember>>(StringComparer.OrdinalIgnoreCase);
        // Symbols of dead members, grouped by file. Used by trim mode.
        var deadSymbolsByFile = new Dictionary<string, List<ISymbol>>(StringComparer.OrdinalIgnoreCase);
        int deadCount = 0;
        int baselineRescues = 0;

        foreach (var t in EnumerateTypes(engineComp.GlobalNamespace))
        {
            if (t.ContainingAssembly?.Name != CandidateAssembly) continue;
            if (t.IsImplicitlyDeclared) continue;
            if (!seen.Contains(TypeKey(t))) continue; // unreachable type — covered by provably-unreachable.tsv

            var typeFullName = FullName(t);
            foreach (var member in t.GetMembers())
            {
                if (member.IsImplicitlyDeclared) continue;
                if (member is not (IMethodSymbol or IPropertySymbol or IFieldSymbol or IEventSymbol)) continue;
                // Skip accessors — propagated via owning property/event above. Reporting them
                // separately would double-count.
                if (member is IMethodSymbol mm && mm.AssociatedSymbol is (IPropertySymbol or IEventSymbol)) continue;

                if (reachedMembers.Contains(SignatureKey(member))) continue;

                // Baseline rescue.
                var baselineKey = $"{typeFullName.Replace("global::", "")}.{member.Name}";
                if (baseline.Contains(baselineKey))
                {
                    baselineRescues++;
                    continue;
                }

                // Reflection-name rescue: if the member's name appears anywhere as a string
                // literal in reachable code, conservatively assume it could be a reflection
                // target (GetMethod, GetField, SendMessage, Activator). Cheap defensive cover
                // for paths the static walk can't see.
                if (literalStrings.Contains(member.Name))
                {
                    baselineRescues++;
                    continue;
                }

                foreach (var sref in member.DeclaringSyntaxReferences)
                {
                    var path = sref.SyntaxTree.FilePath;
                    if (string.IsNullOrEmpty(path)) continue;
                    if (!deadByFile.TryGetValue(path, out var list))
                    {
                        list = new();
                        deadByFile[path] = list;
                    }
                    if (!deadSymbolsByFile.TryGetValue(path, out var symList))
                    {
                        symList = new();
                        deadSymbolsByFile[path] = symList;
                    }
                    symList.Add(member);
                    list.Add(new DeadMember(
                        Kind: member switch
                        {
                            IMethodSymbol mx when mx.MethodKind == MethodKind.Constructor => "ctor",
                            IMethodSymbol => "method",
                            IPropertySymbol => "property",
                            IFieldSymbol => "field",
                            IEventSymbol => "event",
                            _ => "?"
                        },
                        TypeFullName: typeFullName,
                        MemberName: member.Name,
                        Signature: member.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)));
                    deadCount++;
                }
            }
        }

        Console.Error.WriteLine($"[summary] dead members: {deadCount} (baseline rescues: {baselineRescues})");

        await using (var w = new StreamWriter(deadMembersPath))
        {
            await w.WriteLineAsync("file_path\tkind\ttype\tmember_name\tsignature");
            foreach (var (path, members) in deadByFile.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
            {
                var rel = Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
                foreach (var m in members)
                    await w.WriteLineAsync($"{rel}\t{m.Kind}\t{m.TypeFullName}\t{m.MemberName}\t{m.Signature}");
            }
        }
        Console.Error.WriteLine($"[output] wrote: {deadMembersPath}");

        // Subset for actionable scope: Shim/Generated/*.g.cs files.
        var generatedDeadPath = Path.Combine(Path.GetDirectoryName(outputPath)!, "dead-members-generated.tsv");
        int genDeadCount = 0;
        int genFiles = 0;
        await using (var w = new StreamWriter(generatedDeadPath))
        {
            await w.WriteLineAsync("file_path\tkind\ttype\tmember_name\tsignature");
            foreach (var (path, members) in deadByFile.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
            {
                if (!path.Replace('\\', '/').Contains("/Shim/Generated/")) continue;
                genFiles++;
                var rel = Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
                foreach (var m in members)
                {
                    await w.WriteLineAsync($"{rel}\t{m.Kind}\t{m.TypeFullName}\t{m.MemberName}\t{m.Signature}");
                    genDeadCount++;
                }
            }
        }
        Console.Error.WriteLine($"[output] wrote: {generatedDeadPath} ({genDeadCount} dead in {genFiles} generated files)");

        // Trim mode moved below so it has access to both deadSymbolsByFile (member-level)
        // and the deletableFiles list (type-level) — fully-unreachable files must be
        // deleted before partial trims so their stale override declarations don't break
        // the build when a reachable base method gets stripped.

        // Enumerate every type in SVSim.BattleEngine. Group by file path.
        // A file is fully deletable iff every type declared in it is unreachable.
        var byFile = new Dictionary<string, FileRecord>(StringComparer.OrdinalIgnoreCase);
        // typeFullName -> set of files declaring it (across partials).
        var typeFiles = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
        // file path -> list of (TypeSymbol, reachable) for partial-mixed type-level trim.
        var typeSymbolsByFile = new Dictionary<string, List<(INamedTypeSymbol sym, bool reachable)>>(StringComparer.OrdinalIgnoreCase);
        int totalEngineTypes = 0;
        int unreachableEngineTypes = 0;

        foreach (var t in EnumerateTypes(engineComp.GlobalNamespace))
        {
            if (t.ContainingAssembly?.Name != CandidateAssembly) continue;
            // Skip implicitly-declared (compiler-generated, no source). These aren't deletion candidates.
            if (t.IsImplicitlyDeclared) continue;
            // Skip namespaces (shouldn't appear via GetTypeMembers but defensive).
            if (t.TypeKind == TypeKind.Error) continue;

            totalEngineTypes++;
            bool reachable = seen.Contains(TypeKey(t));
            if (!reachable) unreachableEngineTypes++;

            var typeFull = FullName(t);
            if (!typeFiles.TryGetValue(typeFull, out var typeFilesSet))
            {
                typeFilesSet = new(StringComparer.OrdinalIgnoreCase);
                typeFiles[typeFull] = typeFilesSet;
            }

            foreach (var sref in t.DeclaringSyntaxReferences)
            {
                var path = sref.SyntaxTree.FilePath;
                if (string.IsNullOrEmpty(path)) continue;
                if (!byFile.TryGetValue(path, out var rec))
                {
                    rec = new FileRecord();
                    byFile[path] = rec;
                }
                rec.Types.Add((typeFull, reachable));
                typeFilesSet.Add(path);

                if (!typeSymbolsByFile.TryGetValue(path, out var tsl))
                {
                    tsl = new();
                    typeSymbolsByFile[path] = tsl;
                }
                tsl.Add((t, reachable));
            }
        }

        Console.Error.WriteLine($"[summary] engine types: total={totalEngineTypes} unreachable={unreachableEngineTypes}");

        var rawDeletableFiles = byFile
            .Where(kv => kv.Value.Types.Count > 0 && kv.Value.Types.All(x => !x.reachable))
            .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
            .ToList();

        // Partial-class split-file guard. If a candidate-deletable file declares type T but T
        // ALSO has a partial declaration in another file we are NOT deleting, removing this
        // file leaves the surviving partial with its base-clause/interface-list pointing at
        // members that lived only here — CS0535. Strip such files out of the deletable set
        // and treat them as partial-mixed instead.
        var rawDeletablePaths = new HashSet<string>(rawDeletableFiles.Select(kv => kv.Key), StringComparer.OrdinalIgnoreCase);
        var deletableFiles = new List<KeyValuePair<string, FileRecord>>();
        var splitRetained = new List<string>();
        foreach (var kv in rawDeletableFiles)
        {
            bool safe = true;
            foreach (var (typeFull, _) in kv.Value.Types)
            {
                if (!typeFiles.TryGetValue(typeFull, out var allPaths)) continue;
                foreach (var p in allPaths)
                {
                    if (StringComparer.OrdinalIgnoreCase.Equals(p, kv.Key)) continue;
                    if (!rawDeletablePaths.Contains(p))
                    {
                        safe = false;
                        break;
                    }
                }
                if (!safe) break;
            }
            if (safe) deletableFiles.Add(kv);
            else splitRetained.Add(kv.Key);
        }
        if (splitRetained.Count > 0)
            Console.Error.WriteLine($"[guard] partial-split-file retains: {splitRetained.Count} (e.g. {splitRetained[0]})");

        var partialFiles = byFile
            .Where(kv => kv.Value.Types.Any(x => !x.reachable) && kv.Value.Types.Any(x => x.reachable))
            .OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
            .ToList();

        Console.Error.WriteLine($"[summary] files fully deletable: {deletableFiles.Count}");
        Console.Error.WriteLine($"[summary] files partial (mixed):  {partialFiles.Count}");

        Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);

        await using (var w = new StreamWriter(outputPath))
        {
            await w.WriteLineAsync("file_path\ttype_count\ttype_names");
            foreach (var (path, rec) in deletableFiles)
            {
                var rel = Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
                var names = string.Join(",", rec.Types.Select(x => x.full));
                await w.WriteLineAsync($"{rel}\t{rec.Types.Count}\t{names}");
            }
        }

        var partialPath = Path.Combine(Path.GetDirectoryName(outputPath)!, "partial-unreachable.tsv");
        await using (var w = new StreamWriter(partialPath))
        {
            await w.WriteLineAsync("file_path\tdead_count\tlive_count\tdead_types\tlive_types");
            foreach (var (path, rec) in partialFiles)
            {
                var rel = Path.GetRelativePath(repoRoot, path).Replace('\\', '/');
                var dead = rec.Types.Where(x => !x.reachable).Select(x => x.full).ToList();
                var live = rec.Types.Where(x => x.reachable).Select(x => x.full).ToList();
                await w.WriteLineAsync($"{rel}\t{dead.Count}\t{live.Count}\t{string.Join(",", dead)}\t{string.Join(",", live)}");
            }
        }

        Console.Error.WriteLine($"[output] wrote: {outputPath}");
        Console.Error.WriteLine($"[output] wrote: {partialPath}");

        if (trimMode)
        {
            // 1. Delete fully-unreachable files. Their declarations would otherwise have
            //    `override` clauses pointing at trimmed base methods, breaking the build.
            int deletedFullFiles = 0;
            foreach (var (path, _) in deletableFiles)
            {
                if (File.Exists(path))
                {
                    File.Delete(path);
                    deletedFullFiles++;
                }
            }
            Console.Error.WriteLine($"[trim] fully-unreachable files deleted: {deletedFullFiles}");

            // 2. Surgical-trim member declarations from files we keep.
            await ApplyTrim(deadSymbolsByFile, repoRoot);

            // 3. Type-level surgical trim of partial-mixed Shim files. The bulk-delete
            //    path covers fully-unreachable files; this covers files where some types
            //    are dead but others (live) hold the file open. Targets the long-tail
            //    `_ShimAnchor` namespace-keepers and dead no-op stubs in Shim/External/,
            //    Shim/GodObjects/, Shim/UnityEngine/, Shim/View/.
            await ApplyTypeTrim(typeSymbolsByFile, repoRoot);
        }
        return 0;
    }

    private static async Task ApplyTypeTrim(
        Dictionary<string, List<(INamedTypeSymbol sym, bool reachable)>> typeSymbolsByFile,
        string repoRoot)
    {
        int filesEdited = 0;
        int typesRemoved = 0;

        foreach (var (filePath, types) in typeSymbolsByFile.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
        {
            // Scope to Shim/. Engine/ files in partial state are handled member-level above
            // and a "dead type, live file" case in Engine/ would be unusual.
            var norm = filePath.Replace('\\', '/');
            if (!norm.Contains("/Shim/")) continue;
            if (!File.Exists(filePath)) continue;

            // Find dead TOP-LEVEL types whose only declaring file is this one (don't touch
            // types whose partials live across multiple files — same split-file safety as
            // the file-delete pass).
            var deadHere = types
                .Where(x => !x.reachable && x.sym.ContainingType is null)
                .Select(x => x.sym)
                .ToList();
            if (deadHere.Count == 0) continue;

            // Collect syntax nodes to remove.
            var nodesByTree = new Dictionary<SyntaxTree, List<SyntaxNode>>();
            foreach (var sym in deadHere)
            {
                foreach (var sref in sym.DeclaringSyntaxReferences)
                {
                    if (sref.SyntaxTree.FilePath != filePath) continue;
                    var node = sref.GetSyntax();
                    var typeDecl = node.AncestorsAndSelf()
                        .OfType<Microsoft.CodeAnalysis.CSharp.Syntax.MemberDeclarationSyntax>()
                        .FirstOrDefault();
                    if (typeDecl is null) continue;
                    if (!nodesByTree.TryGetValue(sref.SyntaxTree, out var list))
                    {
                        list = new();
                        nodesByTree[sref.SyntaxTree] = list;
                    }
                    if (!list.Contains(typeDecl)) list.Add(typeDecl);
                }
            }
            if (nodesByTree.Count == 0) continue;

            var (tree, matched) = nodesByTree.First();
            var root = await tree.GetRootAsync();
            var newRoot = root.RemoveNodes(matched, Microsoft.CodeAnalysis.SyntaxRemoveOptions.KeepNoTrivia);
            if (newRoot is null) continue;

            await File.WriteAllTextAsync(filePath, newRoot.ToFullString());
            filesEdited++;
            typesRemoved += matched.Count;
        }

        Console.Error.WriteLine($"[type-trim] files edited: {filesEdited}, types removed: {typesRemoved}");
    }

    private static async Task ApplyTrim(
        Dictionary<string, List<ISymbol>> deadSymbolsByFile,
        string repoRoot)
    {
        int filesEdited = 0;
        int filesDeleted = 0;
        int nodesRemoved = 0;

        foreach (var (filePath, members) in deadSymbolsByFile.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase))
        {
            // Trim scope: every BattleEngine source file. Shim/External/ third-party SDK
            // stubs (Steamworks, Facebook, MessagePack, ZXing, CriWare, Spine, BestHTTP,
            // GooglePlayGames, etc.) only existed because the decompiled engine UI called
            // them; with the UI gone, nothing in BattleNode reaches them. The same logic
            // applies to Shim/UnityEngine/* (UI/Loading API stubs) and Shim/View/* — they
            // were defensive surface for UI paths that no longer exist headless. Trim them
            // all; the analyzer's protection rules + the baseline-rescue net keep anything
            // BattleNode actually calls. Engine/ never had a /Shim/ subdir, but the check
            // is kept for clarity.
            var norm = filePath.Replace('\\', '/');
            bool inScope = norm.Contains("/SVSim.BattleEngine/");
            if (!inScope) continue;
            if (!File.Exists(filePath)) continue;

            // The symbols' DeclaringSyntaxReferences point at the workspace compilation's
            // syntax tree for this file. RemoveNodes operates on node identity within that
            // tree — no name-matching needed, no risk of collapsing distinct members that
            // happen to share an unqualified name (Clear() on outer instance vs nested
            // Defaults.loopType etc).
            var nodesByTree = new Dictionary<SyntaxTree, List<SyntaxNode>>();
            foreach (var sym in members)
            {
                foreach (var sref in sym.DeclaringSyntaxReferences)
                {
                    if (sref.SyntaxTree.FilePath != filePath) continue;
                    var node = sref.GetSyntax();
                    var memberDecl = node.AncestorsAndSelf()
                        .OfType<Microsoft.CodeAnalysis.CSharp.Syntax.MemberDeclarationSyntax>()
                        .FirstOrDefault();
                    if (memberDecl is null) continue;
                    if (!nodesByTree.TryGetValue(sref.SyntaxTree, out var list))
                    {
                        list = new();
                        nodesByTree[sref.SyntaxTree] = list;
                    }
                    if (!list.Contains(memberDecl)) list.Add(memberDecl);
                }
            }
            if (nodesByTree.Count == 0) continue;

            var (tree, matched) = nodesByTree.First();
            var root = await tree.GetRootAsync();

            var newRoot = root.RemoveNodes(matched, Microsoft.CodeAnalysis.SyntaxRemoveOptions.KeepNoTrivia);
            if (newRoot is null) continue;

            // Do NOT delete the file even if every member is removed: the partial-class
            // declaration carries the type's base-clause/interface-list which other
            // generated files (e.g. _BaseClauses.g.cs) depend on, and a partial class
            // declared nowhere else would be erased entirely. Just write back the shell.
            await File.WriteAllTextAsync(filePath, newRoot.ToFullString());
            filesEdited++;
            nodesRemoved += matched.Count;
        }

        Console.Error.WriteLine($"[trim] files edited: {filesEdited}, files deleted: {filesDeleted}, nodes removed: {nodesRemoved}");
    }

    private static string? MemberKey(SyntaxNode node)
    {
        switch (node)
        {
            case Microsoft.CodeAnalysis.CSharp.Syntax.MethodDeclarationSyntax m:
                return $"M:{m.Identifier.ValueText}({string.Join(",", m.ParameterList.Parameters.Select(p => p.Type?.ToString() ?? ""))})";
            case Microsoft.CodeAnalysis.CSharp.Syntax.ConstructorDeclarationSyntax c:
                return $"C:{c.Identifier.ValueText}({string.Join(",", c.ParameterList.Parameters.Select(p => p.Type?.ToString() ?? ""))})";
            case Microsoft.CodeAnalysis.CSharp.Syntax.PropertyDeclarationSyntax p:
                return $"P:{p.Identifier.ValueText}";
            case Microsoft.CodeAnalysis.CSharp.Syntax.EventDeclarationSyntax e:
                return $"E:{e.Identifier.ValueText}";
            case Microsoft.CodeAnalysis.CSharp.Syntax.EventFieldDeclarationSyntax ef:
                var n0 = ef.Declaration.Variables.FirstOrDefault()?.Identifier.ValueText;
                return n0 is null ? null : $"EF:{n0}";
            case Microsoft.CodeAnalysis.CSharp.Syntax.FieldDeclarationSyntax f:
                // Multiple variables in one declaration: key on the first. The trimmer only
                // emits whole-declaration removal — if some variables in a multi-decl are
                // alive, the analyzer's per-symbol output already separated them, so they
                // get distinct DeclaringSyntaxReferences. Keep simple: key on first.
                var fname = f.Declaration.Variables.FirstOrDefault()?.Identifier.ValueText;
                return fname is null ? null : $"F:{fname}";
            case Microsoft.CodeAnalysis.CSharp.Syntax.IndexerDeclarationSyntax ix:
                return $"I:[{string.Join(",", ix.ParameterList.Parameters.Select(p => p.Type?.ToString() ?? ""))}]";
            default:
                return null;
        }
    }

    // Display-string signature used as a cross-compilation-stable member identity.
    // For methods, includes container, name, and parameter types.
    private static readonly SymbolDisplayFormat KeyFormat = new(
        globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted,
        typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
        genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
        memberOptions: SymbolDisplayMemberOptions.IncludeContainingType
            | SymbolDisplayMemberOptions.IncludeType
            | SymbolDisplayMemberOptions.IncludeParameters,
        parameterOptions: SymbolDisplayParameterOptions.IncludeType,
        miscellaneousOptions: SymbolDisplayMiscellaneousOptions.UseSpecialTypes);

    private static string SignatureKey(ISymbol sym)
    {
        var orig = sym.OriginalDefinition;
        return orig.ToDisplayString(KeyFormat);
    }

    private static void RecordMember(ISymbol? sym, HashSet<string> reached)
    {
        if (sym is null) return;
        // Extension method invocations resolve to a "reduced" IMethodSymbol whose identity
        // differs from the static declaration. Walk to the underlying static method.
        if (sym is IMethodSymbol rm && rm.ReducedFrom is not null) sym = rm.ReducedFrom;
        switch (sym)
        {
            case IMethodSymbol m:
                reached.Add(SignatureKey(m));
                if (m.AssociatedSymbol is not null)
                    reached.Add(SignatureKey(m.AssociatedSymbol));
                break;
            case IPropertySymbol p:
                reached.Add(SignatureKey(p));
                if (p.GetMethod is not null) reached.Add(SignatureKey(p.GetMethod));
                if (p.SetMethod is not null) reached.Add(SignatureKey(p.SetMethod));
                break;
            case IFieldSymbol f:
                reached.Add(SignatureKey(f));
                break;
            case IEventSymbol e:
                reached.Add(SignatureKey(e));
                if (e.AddMethod is not null) reached.Add(SignatureKey(e.AddMethod));
                if (e.RemoveMethod is not null) reached.Add(SignatureKey(e.RemoveMethod));
                break;
        }
    }

    private static void AddSymbol(ISymbol? sym, Queue<INamedTypeSymbol> q)
    {
        if (sym is null) return;
        // Extension method invocations resolve to a reduced IMethodSymbol whose
        // ContainingType is the receiver, not the static-method-declaring class.
        if (sym is IMethodSymbol rm && rm.ReducedFrom is not null) sym = rm.ReducedFrom;
        switch (sym)
        {
            case INamedTypeSymbol nt:
                Enqueue(nt, q);
                foreach (var ta in nt.TypeArguments) EnqueueAny(ta, q);
                break;
            case IMethodSymbol m:
                if (m.ContainingType is not null) Enqueue(m.ContainingType, q);
                EnqueueAny(m.ReturnType, q);
                foreach (var p in m.Parameters) EnqueueAny(p.Type, q);
                foreach (var ta in m.TypeArguments) EnqueueAny(ta, q);
                break;
            case IPropertySymbol p:
                if (p.ContainingType is not null) Enqueue(p.ContainingType, q);
                EnqueueAny(p.Type, q);
                break;
            case IFieldSymbol f:
                if (f.ContainingType is not null) Enqueue(f.ContainingType, q);
                EnqueueAny(f.Type, q);
                break;
            case IEventSymbol e:
                if (e.ContainingType is not null) Enqueue(e.ContainingType, q);
                EnqueueAny(e.Type, q);
                break;
        }
    }

    private static void EnqueueAny(ITypeSymbol? type, Queue<INamedTypeSymbol> q)
    {
        if (type is null) return;
        switch (type)
        {
            case IArrayTypeSymbol a:
                EnqueueAny(a.ElementType, q);
                break;
            case IPointerTypeSymbol p:
                EnqueueAny(p.PointedAtType, q);
                break;
            case INamedTypeSymbol nt:
                Enqueue(nt, q);
                foreach (var ta in nt.TypeArguments) EnqueueAny(ta, q);
                break;
            case ITypeParameterSymbol tp:
                foreach (var con in tp.ConstraintTypes) EnqueueAny(con, q);
                break;
        }
    }

    private static void Enqueue(INamedTypeSymbol type, Queue<INamedTypeSymbol> q)
    {
        q.Enqueue((INamedTypeSymbol)type.OriginalDefinition);
    }

    private static IEnumerable<INamedTypeSymbol> EnumerateTypes(INamespaceOrTypeSymbol root)
    {
        if (root is INamespaceSymbol ns)
        {
            foreach (var t in ns.GetTypeMembers())
            {
                yield return t;
                foreach (var n in EnumerateNestedTypes(t)) yield return n;
            }
            foreach (var sub in ns.GetNamespaceMembers())
                foreach (var t in EnumerateTypes(sub))
                    yield return t;
        }
        else if (root is INamedTypeSymbol nt)
        {
            yield return nt;
            foreach (var n in EnumerateNestedTypes(nt)) yield return n;
        }
    }

    private static IEnumerable<INamedTypeSymbol> EnumerateNestedTypes(INamedTypeSymbol t)
    {
        foreach (var n in t.GetTypeMembers())
        {
            yield return n;
            foreach (var nn in EnumerateNestedTypes(n)) yield return nn;
        }
    }

    private static bool HasNUnitAttribute(INamedTypeSymbol t)
    {
        foreach (var a in t.GetAttributes())
            if (NUnitAttributeNames.Contains(a.AttributeClass?.Name ?? ""))
                return true;
        foreach (var m in t.GetMembers())
            foreach (var a in m.GetAttributes())
                if (NUnitAttributeNames.Contains(a.AttributeClass?.Name ?? ""))
                    return true;
        return false;
    }

    private static string FullName(INamedTypeSymbol t) =>
        t.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);

    private static string FindRepoRoot()
    {
        var dir = AppContext.BaseDirectory;
        for (int i = 0; i < 10; i++)
        {
            if (File.Exists(Path.Combine(dir, "DCGEngine.sln")))
                return Path.GetFullPath(dir);
            var parent = Directory.GetParent(dir)?.FullName;
            if (parent is null || parent == dir) break;
            dir = parent;
        }
        throw new InvalidOperationException(
            "could not locate DCGEngine.sln walking up from " + AppContext.BaseDirectory);
    }

    private sealed class FileRecord
    {
        public List<(string full, bool reachable)> Types { get; } = new();
    }

    private sealed record DeadMember(string Kind, string TypeFullName, string MemberName, string Signature);
}
