using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;

namespace WslcApiDump;

/// <summary>
/// M1 spike (a), phase 1 (docs/WINDOWS_PORT.md §13): what does Microsoft.WSL.Containers
/// ACTUALLY look like, and where is <c>WslcFacade.cs</c> wrong?
///
/// Everything the Windows container subsystem rests on — items 5, 6 and 7 — was written against
/// documentation, on a machine with no WSL. This program is the cheapest possible way to convert
/// that pile of assumptions into a worklist, and it is written entirely in reflection so that it
/// cannot fail to build no matter how wrong the assumptions turn out to be.
///
/// It never mutates anything unless asked: the default run only reads type metadata. `--probe`
/// additionally calls the two safe statics (`GetVersion`, `GetMissingComponents`). `--session`
/// goes further and creates a real session, then a SECOND one with the same name, to settle
/// whether the compat SDK can re-adopt a running session. Answered on 2.9.4: it cannot. The
/// constructor is lazy and always succeeds; `Start()` is where the service is consulted, and it
/// refuses with `ERROR_ALREADY_EXISTS`. So reattach needs the internal COM interface, which is
/// what D13 assumes (docs/WINDOWS_PORT.md §13.1).
///
/// `--internal` then asks the question D13 itself rests on and nobody has answered: is that
/// internal interface reachable? `wslc.idl` declares no activatable class, so there is no CLSID
/// to name — see <see cref="InternalComProbe"/>. `--internal-call` also calls through it.
/// </summary>
internal static class Program
{
    /// The C#/WinRT PROJECTION assembly. The NuGet package is "Microsoft.WSL.Containers" but
    /// the managed assembly it ships is `lib/net8.0-windows10.0.19041.0/wslcsdkcs.dll`, so
    /// loading it by package name fails — which is exactly the first thing this tool found.
    private const string AssemblyName = "wslcsdkcs";

    /// The API namespace. Filtering on it is not tidiness — it is correctness. A C#/WinRT
    /// projection also exports `ABI.Microsoft.WSL.Containers.*` marshalling plumbing whose types
    /// have the SAME short names (`Session`, `Container`, `ProcessSettings`), and `ABI.` sorts
    /// first. Matching assumptions by short name therefore checked every member against the
    /// marshalling struct and reported 42 false MISSINGs, with `CreateMarshaler` offered as the
    /// nearest name — which is the tell.
    private const string ApiNamespace = "Microsoft.WSL.Containers";

    private static int Main(string[] args)
    {
        var probe = args.Contains("--probe") || args.Contains("--session");
        var session = args.Contains("--session");
        var outPath = ArgValue(args, "--out") ?? "wslc-api-dump.txt";

        Assembly assembly;
        try
        {
            assembly = Assembly.Load(new AssemblyName(AssemblyName));
        }
        catch (Exception e)
        {
            Console.Error.WriteLine($"could not load {AssemblyName}: {e.Message}");
            Console.Error.WriteLine(
                "Is the preview NuGet restored? `dotnet restore windows/spikes/WslcApiDump`.");
            return 2;
        }

        var exported = assembly.GetExportedTypes().OrderBy(t => t.FullName, StringComparer.Ordinal).ToArray();
        var types = exported.Where(t => t.Namespace == ApiNamespace).ToArray();
        Console.WriteLine(
            $"{AssemblyName} {assembly.GetName().Version} — {types.Length} types in {ApiNamespace} "
            + $"({exported.Length - types.Length} more are ABI/marshalling plumbing)");
        Console.WriteLine();

        var report = new StringBuilder();
        report.AppendLine($"# {AssemblyName} {assembly.GetName().Version}");
        report.AppendLine($"# location: {assembly.Location}");
        report.AppendLine();
        // The dump defaults to the API namespace for the same reason the checks do; `--all-types`
        // includes the ABI plumbing for when the projection itself is what's being debugged.
        foreach (var type in args.Contains("--all-types") ? exported : types) DescribeType(type, report);
        File.WriteAllText(outPath, report.ToString());
        Console.WriteLine($"full API dump → {Path.GetFullPath(outPath)}");
        Console.WriteLine();

        var missing = CheckAssumptions(types);

        // Components are probed BEFORE anything else that touches the service, because that one
        // call explains every other failure: nothing installed answers REGDB_E_CLASSNOTREG
        // (0x80040154), while an installed-but-too-old WSL answers ERROR_NOT_SUPPORTED
        // (0x80070032). Reporting those as unexplained COM errors buries the actual finding.
        var componentsMissing = probe ? ProbeStatics(types) : null;
        if (session)
        {
            if (componentsMissing is { Count: > 0 })
            {
                Console.WriteLine();
                Console.WriteLine("skipping --session: the components above are missing, so creating "
                    + "a session would only fail the same way. Fix those first.");
            }
            else
            {
                ProbeSession(
                    types, ArgValue(args, "--session-name") ?? "nucleic-spike",
                    keep: args.Contains("--keep"));
            }
        }

        // Independent of the compat-surface checks above: this asks whether the SERVICE-INTERNAL
        // interface can be reached at all, which is what D13's enumeration/reattach/pty arm
        // hangs on (docs/WINDOWS_PORT.md §13.2).
        if (args.Contains("--internal") || args.Contains("--internal-call"))
            InternalComProbe.Run(
                callThrough: args.Contains("--internal-call"),
                // Shares --session's name so `--session --keep --internal-call` creates a session
                // and then re-adopts it through the internal interface in one run — which is the
                // only way to test the handoff, and the whole §2.3 reattach story in miniature.
                sessionName: args.Contains("--session") || args.Contains("--session-name")
                    ? ArgValue(args, "--session-name") ?? "nucleic-spike"
                    : null);

        Console.WriteLine();
        if (componentsMissing is { Count: > 0 })
        {
            Console.WriteLine($"RESULT: this machine cannot run wslc yet — missing "
                + $"{string.Join(", ", componentsMissing)}.");
            foreach (var component in componentsMissing)
                Console.WriteLine($"        {component}: {Remedy(component)}");
            Console.WriteLine("        Then run this again. This is the §8 onboarding condition, "
                + "not a defect.");
        }
        else if (missing == 0)
        {
            Console.WriteLine("RESULT: every WslcFacade assumption is present — the package matches "
                + "the surface recorded in docs/WINDOWS_PORT.md §13.1.");
        }
        else
        {
            Console.WriteLine($"RESULT: {missing} assumption(s) wrong. The recorded surface is 2.9.3, "
                + "so this means the package MOVED: reconcile §13.1, then fix "
                + "windows/NucleicBroker/Wslc/WslcFacade.cs — and nowhere else (that is what IWslc "
                + "is for).");
        }
        // Exit 0 either way: a mismatch is this tool's PRODUCT, not its failure. Only a genuinely
        // broken run (assembly missing) is non-zero, so a wrapper script can tell them apart.
        return 0;
    }

    // MARK: - Type dump

    private static void DescribeType(Type type, StringBuilder report)
    {
        var kind = type.IsEnum ? "enum"
            : type.IsInterface ? "interface"
            : type.IsValueType ? "struct"
            : "class";
        report.AppendLine($"{kind} {type.FullName}"
            + (type.BaseType is { } b && b != typeof(object) ? $" : {b.Name}" : ""));

        if (type.IsEnum)
        {
            foreach (var name in Enum.GetNames(type)) report.AppendLine($"    .{name}");
            report.AppendLine();
            return;
        }

        // Every member walk is guarded. These are WinRT projection types: a signature can name a
        // type from an assembly that is present at build time and not at runtime, and one such
        // member must not cost us the other 60 types' worth of report.
        Guarded(report, () => {
            foreach (var ctor in type.GetConstructors())
                report.AppendLine($"    .ctor({Parameters(ctor)})");
        });
        Guarded(report, () => {
            foreach (var property in type.GetProperties(Public).OrderBy(p => p.Name, StringComparer.Ordinal))
                report.AppendLine(
                    $"    {Short(property.PropertyType)} {property.Name} "
                    + $"{{ {(property.CanRead ? "get; " : "")}{(property.CanWrite ? "set; " : "")}}}");
        });
        Guarded(report, () => {
            foreach (var evt in type.GetEvents(Public).OrderBy(e => e.Name, StringComparer.Ordinal))
                report.AppendLine($"    event {Short(evt.EventHandlerType)} {evt.Name}");
        });
        Guarded(report, () => {
            foreach (var method in type.GetMethods(Public)
                .Where(m => !m.IsSpecialName)
                .OrderBy(m => m.Name, StringComparer.Ordinal))
                report.AppendLine($"    {Short(method.ReturnType)} {method.Name}({Parameters(method)})");
        });
        report.AppendLine();
    }

    private static void Guarded(StringBuilder report, Action body)
    {
        try { body(); }
        catch (Exception e) { report.AppendLine($"    <unreadable: {e.GetType().Name}: {e.Message}>"); }
    }

    private const BindingFlags Public =
        BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;

    private static string Parameters(MethodBase m) =>
        string.Join(", ", m.GetParameters().Select(p => $"{Short(p.ParameterType)} {p.Name}"));

    private static string Short(Type? t)
    {
        if (t is null) return "void";
        if (!t.IsGenericType) return t.Name;
        var tick = t.Name.IndexOf('`');
        var name = tick < 0 ? t.Name : t.Name[..tick];
        return $"{name}<{string.Join(", ", t.GetGenericArguments().Select(Short))}>";
    }

    // MARK: - Assumption check (the actual product)

    private static int CheckAssumptions(Type[] types)
    {
        Console.WriteLine("WslcFacade.cs assumptions:");
        Console.WriteLine();
        var wrong = 0;
        foreach (var group in FacadeAssumptions.All.GroupBy(a => a.Type))
        {
            var type = types.FirstOrDefault(t => t.Name == group.Key);
            if (type is null)
            {
                // Summarise rather than repeat: if a whole type is renamed or namespaced away,
                // one line naming it beats a paragraph per member. The per-member "why" only
                // earns its space when the type exists and a single member is wrong.
                wrong += group.Count();
                Console.WriteLine($"  MISSING TYPE  {group.Key} "
                    + $"({group.Count()} member(s): {string.Join(", ", group.Select(a => a.Member))})");
                var near = Nearest(group.Key, types.Select(t => t.Name));
                Console.WriteLine(near.Length > 0
                    ? $"                nearest types: {string.Join(", ", near)}"
                    : "                no similarly-named type — check the dump file's namespaces");
                continue;
            }

            foreach (var assumption in group)
            {
                if (assumption.MemberKind is FacadeAssumptions.Kind.Type || Has(type, assumption))
                {
                    Console.WriteLine($"  ok            {type.Name}.{assumption.Member}");
                    continue;
                }
                wrong++;
                Console.WriteLine($"  MISSING       {type.Name}.{assumption.Member} "
                    + $"({assumption.MemberKind.ToString().ToLowerInvariant()})");
                foreach (var line in Wrap(assumption.Why, 94))
                    Console.WriteLine($"                {line}");
                var near = Nearest(assumption.Member, MemberNames(type));
                if (near.Length > 0)
                    Console.WriteLine($"                nearest: {string.Join(", ", near)}");
            }
        }
        return wrong;
    }

    private static bool Has(Type type, FacadeAssumptions.Assumption a) => a.MemberKind switch
    {
        FacadeAssumptions.Kind.Constructor => type.GetConstructors().Length > 0,
        FacadeAssumptions.Kind.EnumValue => type.IsEnum && Enum.GetNames(type).Contains(a.Member),
        FacadeAssumptions.Kind.Event => type.GetEvent(a.Member) is not null,
        FacadeAssumptions.Kind.Property =>
            type.GetProperty(a.Member) is not null || type.GetField(a.Member) is not null,
        FacadeAssumptions.Kind.Method => type.GetMethods(Public).Any(m => m.Name == a.Member),
        _ => true,
    };

    private static IEnumerable<string> MemberNames(Type type) =>
        type.IsEnum
            ? Enum.GetNames(type)
            : type.GetMembers(Public).Where(m => !m.Name.StartsWith('.')).Select(m => m.Name).Distinct();

    /// <summary>Cheap "did they just rename it" hint: shared prefix or containment, no edit
    /// distance. A three-name shortlist is enough to spot <c>HostGateway</c> vs
    /// <c>HostGatewayAddress</c>, which is the realistic failure mode.</summary>
    /// Hard-wrap so a long rationale can't be mangled into an unreadable fragment by the
    /// console. First line is prefixed "why:", continuations are indented under it.
    private static IEnumerable<string> Wrap(string text, int width)
    {
        var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        var line = new StringBuilder("why: ");
        var any = false;
        foreach (var word in words)
        {
            if (line.Length + word.Length + 1 > width && any)
            {
                yield return line.ToString();
                line = new StringBuilder("     ");
                any = false;
            }
            if (any) line.Append(' ');
            line.Append(word);
            any = true;
        }
        if (any) yield return line.ToString();
    }

    private static string[] Nearest(string wanted, IEnumerable<string> candidates)
    {
        var needle = wanted.TrimStart('.');
        if (needle.Length == 0) return [];
        return candidates
            .Where(c => c.Contains(needle, StringComparison.OrdinalIgnoreCase)
                || needle.Contains(c, StringComparison.OrdinalIgnoreCase)
                || SharedPrefix(c, needle) >= 4)
            .Distinct()
            .Take(3)
            .ToArray();
    }

    private static int SharedPrefix(string a, string b)
    {
        var n = 0;
        while (n < a.Length && n < b.Length && char.ToLowerInvariant(a[n]) == char.ToLowerInvariant(b[n])) n++;
        return n;
    }

    // MARK: - Live probes

    /// The two service statics, which only read state and are safe on any machine.
    ///
    /// Names come from the real 2.9.3 surface (`GetVersion`, `GetMissingComponents`) — the
    /// documented `GetServiceVersion`/`ComponentFlags` shapes in Microsoft Learn's sample are
    /// stale against the shipped package (docs/WINDOWS_PORT.md §13.1).
    private static IReadOnlyList<string>? ProbeStatics(Type[] types)
    {
        Console.WriteLine();
        Console.WriteLine("live probe (read-only):");
        var service = types.FirstOrDefault(t => t.Name == "WslcService");
        if (service is null) { Console.WriteLine("  no WslcService type — skipping"); return null; }

        List<string>? componentsMissing = null;
        // GetMissingComponents first: it answers from OS feature state and works even when the
        // service class isn't registered, so it is the call that EXPLAINS the others.
        foreach (var name in new[] { "GetMissingComponents", "GetVersion" })
        {
            var method = service.GetMethods(Public)
                .FirstOrDefault(m => m.IsStatic && m.Name == name && m.GetParameters().Length == 0);
            if (method is null)
            {
                var any = service.GetMethods(Public).Any(m => m.Name == name);
                Console.WriteLine($"  {name}: {(any ? "present but not static — see the dump" : "absent")}");
                continue;
            }
            try
            {
                var value = method.Invoke(null, null);
                Console.WriteLine($"  {name}() = {Render(value)}");
                if (name == "GetMissingComponents" && value is System.Collections.IEnumerable list)
                {
                    componentsMissing = list.Cast<object?>().Select(v => v?.ToString() ?? "?").ToList();
                    if (componentsMissing.Count > 0)
                        Console.WriteLine("  → wslc is not usable here yet. Calls that reach the "
                            + "service will fail (0x80040154 when nothing is installed, "
                            + "0x80070032 when WSL is present but too old).");
                }
            }
            catch (TargetInvocationException e)
            {
                var inner = e.InnerException;
                // Any COM failure is expected while components are missing — pinning it to one
                // code was wrong: a too-old WSL answers ERROR_NOT_SUPPORTED, not CLASSNOTREG.
                var expected = componentsMissing is { Count: > 0 } && inner is COMException;
                Console.WriteLine($"  {name}() threw {Describe(inner)}"
                    + (expected ? "  ← expected: the components above are missing" : ""));
            }
        }
        return componentsMissing;
    }

    /// Create a real session, then create a SECOND one with the same name.
    ///
    /// The second one is the point. D13 puts session reattach on the internal COM interface
    /// (`IWSLCSessionManager::OpenSessionByName`) precisely because the compat SDK exposes only a
    /// constructor — but nobody has established what that constructor DOES when the name is
    /// already taken. If it attaches, §2.3 broker reattach may not need the internal interface at
    /// all for the session half; if it throws `WSLC_E_SESSION_RESERVED` (0x80040607), D13's
    /// reasoning is confirmed on hardware rather than inferred from an IDL.
    ///
    /// Leaves the session running on purpose: whether it outlives this process is the other half
    /// of the same question. Tear it down with `wslc` when you are done.
    private static void ProbeSession(Type[] types, string name, bool keep)
    {
        Console.WriteLine();
        Console.WriteLine($"live probe (creates session '{name}'):");
        var settingsType = types.FirstOrDefault(t => t.Name == "SessionSettings");
        var sessionType = types.FirstOrDefault(t => t.Name == "Session");
        if (settingsType is null || sessionType is null)
        {
            Console.WriteLine("  SessionSettings/Session absent — skipping");
            return;
        }

        var dataDir = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "Nucleic", "spike", "wslc");
        Directory.CreateDirectory(dataDir);
        Console.WriteLine($"  storage: {dataDir}");

        var first = CreateSession(settingsType, sessionType, name, dataDir, "first");
        if (first is null) return;
        // Hand the live SDK-created session to the internal probe: comparing ITS underlying COM
        // identity against the one OpenSessionByName returns is what settles whether the WinRT
        // projection is the same object or a client-side wrapper (InternalComProbe).
        InternalComProbe.sdkSession = first;

        // Start it — construction alone may not boot the VM (`Session.Start()` is separate).
        var start = sessionType.GetMethods(Public)
            .FirstOrDefault(m => !m.IsStatic && m.Name == "Start" && m.GetParameters().Length == 0);
        if (start is null) { Console.WriteLine("  no Session.Start() — see the dump"); }
        else
        {
            try { start.Invoke(first, null); Console.WriteLine("  Start() ok"); }
            catch (TargetInvocationException e) { Console.WriteLine($"  Start() threw {Describe(e.InnerException)}"); }
        }

        DumpProperties(sessionType, first, "  session properties");

        // THE question (see the doc comment).
        Console.WriteLine();
        Console.WriteLine("  second Session with the SAME name — attach, or reserved?");
        var second = CreateSession(settingsType, sessionType, name, dataDir, "second");
        if (second is null)
        {
            Console.WriteLine("  → REFUSED (see the HRESULT above). D13's premise holds: the compat "
                + "surface cannot re-adopt a session, so reattach needs "
                + "IWSLCSessionManager::OpenSessionByName.");
        }
        else
        {
            // The constructor is LAZY — it only captures settings, so its success says nothing.
            // `Start()` is where the service is actually asked for the session, and therefore
            // where exclusivity is enforced. Judge on that.
            var secondStarted = false;
            string? startFailure = null;
            if (start is null)
            {
                Console.WriteLine("  (no Session.Start() — cannot judge)");
            }
            else
            {
                try { start.Invoke(second, null); secondStarted = true; }
                catch (TargetInvocationException e) { startFailure = Describe(e.InnerException); }
            }

            if (secondStarted)
            {
                Console.WriteLine("  second Start() ok — it did NOT refuse, so it may have re-adopted");
                IdentityTest(sessionType, first, second);
            }
            else if (startFailure is not null)
            {
                Console.WriteLine($"  second Start() threw {startFailure}");
                Console.WriteLine();
                Console.WriteLine("  → REFUSED at Start(). The constructor succeeding meant nothing:");
                Console.WriteLine("    it is lazy, and the service is only consulted by Start().");
                Console.WriteLine("    So the compat SDK CANNOT re-adopt a running session, and D13");
                Console.WriteLine("    holds — §2.3 reattach needs IWSLCSessionManager::OpenSessionByName");
                Console.WriteLine("    (or EnterSession) on the internal COM interface.");
                Console.WriteLine("    Note the code is ERROR_ALREADY_EXISTS, not WSLC_E_SESSION_RESERVED;");
                Console.WriteLine("    the reserved code evidently means something narrower.");
            }
        }
        Console.WriteLine();
        if (keep)
        {
            Console.WriteLine("  --keep: session(s) left running, so you can check whether they "
                + "outlive this process (the other half of the §2.3 question).");
            Console.WriteLine("  Tear down with `wsl --shutdown` — note that `wslc.exe` is NOT on "
                + "PATH by default; it lives beside wsl.exe (try `C:\\Program Files\\WSL\\wslc.exe`).");
        }
        else
        {
            Terminate(sessionType, first, "first");
            Console.WriteLine("  (pass --keep to leave sessions running instead)");
        }
    }

    /// Decide whether the second same-named `Session` re-adopted the first, using ONLY the compat
    /// SDK — no CLI, which matters because `wslc.exe` is not on PATH by default.
    ///
    /// The trick: terminate the FIRST session and see whether the SECOND dies with it. A read that
    /// worked before termination and fails after is the same underlying session answering; a read
    /// that keeps working means two independent sessions were running all along. `GetImages` is
    /// the cheapest read on the surface and mutates nothing.
    private static void IdentityTest(Type sessionType, object first, object second)
    {
        var getImages = sessionType.GetMethods(Public)
            .FirstOrDefault(m => !m.IsStatic && m.Name == "GetImages" && m.GetParameters().Length == 0);
        if (getImages is null) { Console.WriteLine("  (no GetImages — cannot test identity)"); return; }

        Console.WriteLine();
        Console.WriteLine("  identity test: terminate the FIRST, then read from the SECOND");
        if (!Reads(getImages, second, out var before))
        {
            // Only reachable if Start() reported success but the session is unusable anyway.
            Console.WriteLine($"    inconclusive — the second session reads fail already: {before}");
            return;
        }
        Console.WriteLine("    second reads ok before termination");

        if (!Terminate(sessionType, first, "first")) return;
        // The service tears a VM down asynchronously; give it a moment before concluding.
        Thread.Sleep(3000);

        if (Reads(getImages, second, out var after))
        {
            Console.WriteLine("    second STILL reads ok after the first was terminated");
            Console.WriteLine("    → SEPARATE SESSIONS. The name is not an identity: two VMs were "
                + "running. D13 stands — reattach needs IWSLCSessionManager::OpenSessionByName.");
        }
        else
        {
            Console.WriteLine($"    second now fails: {after}");
            Console.WriteLine("    → SAME SESSION. Constructing over an existing name RE-ADOPTS it, "
                + "so the session half of §2.3 reattach is reachable from the compat surface and "
                + "D13's reattach row softens (the container half is unaffected — still no "
                + "enumeration).");
        }
    }

    private static bool Reads(MethodInfo getImages, object session, out string detail)
    {
        try { getImages.Invoke(session, null); detail = "ok"; return true; }
        catch (TargetInvocationException e) { detail = Describe(e.InnerException); return false; }
        catch (Exception e) { detail = Describe(e); return false; }
    }

    private static bool Terminate(Type sessionType, object session, string which)
    {
        var terminate = sessionType.GetMethods(Public)
            .FirstOrDefault(m => !m.IsStatic && m.Name == "Terminate" && m.GetParameters().Length == 0);
        if (terminate is null) { Console.WriteLine("    (no Session.Terminate)"); return false; }
        try { terminate.Invoke(session, null); Console.WriteLine($"    {which} session terminated"); return true; }
        catch (TargetInvocationException e)
        {
            Console.WriteLine($"    {which} Terminate() threw {Describe(e.InnerException)}");
            return false;
        }
    }

    private static object? CreateSession(
        Type settingsType, Type sessionType, string name, string dataDir, string which)
    {
        object? settings;
        try
        {
            settings = Activator.CreateInstance(settingsType, name, dataDir);
        }
        catch (Exception e)
        {
            Console.WriteLine($"  SessionSettings(name, storagePath) rejected: {Describe(Unwrap(e))}");
            Console.WriteLine("  → constructor shape differs; see the .ctor lines in the dump file");
            return null;
        }
        try
        {
            return Activator.CreateInstance(sessionType, settings);
        }
        catch (Exception e)
        {
            Console.WriteLine($"  {which} Session(settings) threw {Describe(Unwrap(e))}");
            return null;
        }
    }

    private static void DumpProperties(Type type, object instance, string heading)
    {
        var properties = type.GetProperties(Public)
            .Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
            .OrderBy(p => p.Name, StringComparer.Ordinal)
            .ToArray();
        if (properties.Length == 0)
        {
            Console.WriteLine($"{heading}: none (this type is all methods and events)");
            return;
        }
        Console.WriteLine($"{heading}:");
        foreach (var property in properties)
        {
            string rendered;
            try { rendered = Render(property.GetValue(instance)); }
            catch (Exception e) { rendered = $"<threw {Describe(Unwrap(e))}>"; }
            Console.WriteLine($"    {property.Name} = {rendered}");
        }
    }

    private static Exception? Unwrap(Exception e) =>
        e is TargetInvocationException { InnerException: { } inner } ? inner : e;

    /// Exceptions from this API are COM HRESULTs, and the number is the useful part: the WSLC
    /// codes are documented in `wslc.idl` (0x80040601 image-not-found … 0x80040607
    /// session-reserved … 0x8004060F session-not-found).
    private static string Describe(Exception? e)
    {
        if (e is null) return "an unknown error";
        var code = e is COMException com ? com.HResult : e.HResult;
        // COM messages from this API are often empty, which leaves a bare hex code and no clue —
        // and when they are NOT empty they tend to arrive twice, because a WinRT failure carries
        // the text in both the restricted-error-info and the HRESULT message. Collapse both cases.
        var message = string.IsNullOrWhiteSpace(e.Message) ? KnownHResult(code) : OneLine(e.Message);
        return $"{e.GetType().Name} (0x{code:X8}): {message}";
    }

    /// Fold a multi-line, often-duplicated exception message onto one line.
    private static string OneLine(string message) =>
        string.Join(" — ", message
            .Split('\r', '\n')
            .Select(line => line.Trim())
            .Where(line => line.Length > 0)
            .Distinct());

    /// The codes actually seen coming out of wslc, decoded. The WSLC_E_* range is documented in
    /// `wslc.idl`; the other two are ordinary Windows errors that mean very different things and
    /// are easy to confuse — "not registered" is *nothing installed*, "not supported" is
    /// *installed but too old*, which is a completely different fix.
    private static string KnownHResult(int code) => (uint)code switch
    {
        0x80040154 => "REGDB_E_CLASSNOTREG — the WSLC service class is not registered "
            + "(nothing to talk to)",
        0x80070032 => "ERROR_NOT_SUPPORTED — the installed WSL does not implement this call "
            + "(almost always: WSL is older than the SDK)",
        0x80070005 => "E_ACCESSDENIED",
        0x800700B7 => "ERROR_ALREADY_EXISTS — a session of that name is already running",
        0x8000000E => "E_ILLEGAL_METHOD_CALL — the object is not in a state to serve this call "
            + "(e.g. Session.Start() was never called, or failed)",
        0x80040601 => "WSLC_E_IMAGE_NOT_FOUND",
        0x80040603 => "WSLC_E_CONTAINER_NOT_FOUND",
        0x80040605 => "WSLC_E_CONTAINER_NOT_RUNNING",
        0x80040607 => "WSLC_E_SESSION_RESERVED — that session name is already taken",
        0x80040608 => "WSLC_E_INVALID_SESSION_NAME",
        0x8004060B => "WSLC_E_SDK_UPDATE_NEEDED",
        0x8004060C => "WSLC_E_CONTAINER_DISABLED",
        0x8004060F => "WSLC_E_SESSION_NOT_FOUND",
        _ => "no message",
    };

    /// What to actually DO about each missing component. These are not interchangeable, and the
    /// difference cost a round trip: `wsl --install` fixes VirtualMachinePlatform and does nothing
    /// for WslPackage, which needs an *update* — and specifically a pre-release one, because the
    /// SDK's 2.9.3 is ahead of the Store channel.
    private static string Remedy(string component) => component switch
    {
        "VirtualMachinePlatform" =>
            "`wsl --install`, then REBOOT (this is an OS optional feature)",
        "WslPackage" =>
            "`wsl --update --pre-release` then `wsl --shutdown` — WSL is installed but older than "
            + "the SDK. 2.9.3 is pre-release-only, so a plain `wsl --update` will NOT get there. "
            + "Confirm with `wsl --version` (need >= 2.9.3).",
        "SdkNeedsUpdate" =>
            "the Microsoft.WSL.Containers pin is NEWER than the installed service — either update "
            + "WSL further or pin the package back",
        _ => "see `wsl --help`",
    };

    private static string Render(object? value) => Render(value, depth: 0);

    /// Render a value for the console.
    ///
    /// The wrinkle worth knowing: a WinRT projection class does NOT override `ToString()`, so the
    /// default gives you its type name and nothing else — `GetVersion()` printed
    /// "Microsoft.WSL.Containers.ServiceVersion" instead of the version it had just fetched. When
    /// `ToString()` is that unhelpful, dump the readable properties instead. `depth` bounds the
    /// recursion, since a projection object graph can be cyclic.
    private static string Render(object? value, int depth) => value switch
    {
        null => "null",
        string s => $"\"{s}\"",
        System.Collections.IEnumerable e and not string =>
            "[" + string.Join(", ", e.Cast<object?>().Select(v => Render(v, depth + 1))) + "]",
        _ => Structured(value, depth),
    };

    private static string Structured(object value, int depth)
    {
        var type = value.GetType();
        var text = value.ToString();
        // A meaningful ToString() is one that isn't just the type's own name.
        if (!string.IsNullOrEmpty(text) && text != type.FullName && text != type.Name) return text;
        if (depth >= 2) return type.Name;

        PropertyInfo[] properties;
        try
        {
            properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
                .Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
                .OrderBy(p => p.Name, StringComparer.Ordinal)
                .ToArray();
        }
        catch { return type.Name; }
        if (properties.Length == 0) return text ?? type.Name;

        var parts = properties.Select(p =>
        {
            try { return $"{p.Name}={Render(p.GetValue(value), depth + 1)}"; }
            catch (Exception e) { return $"{p.Name}=<{Unwrap(e)?.GetType().Name}>"; }
        });
        return $"{type.Name} {{ {string.Join(", ", parts)} }}";
    }

    private static string? ArgValue(string[] args, string flag)
    {
        var i = Array.IndexOf(args, flag);
        return i >= 0 && i + 1 < args.Length && !args[i + 1].StartsWith("--") ? args[i + 1] : null;
    }
}
