#if USE_WSLC
using System.Net.NetworkInformation;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using Sdk = Microsoft.WSL.Containers;
using Windows.Storage.Streams;

namespace NucleicBroker.Wslc;

/// <summary>
/// The real <c>Microsoft.WSL.Containers</c> adapter (docs/WINDOWS_PORT.md §1.5, §3.2), compiled
/// only with <c>-p:UseWslc=true</c>.
///
/// Written against the **measured** 2.9.3 surface, not the documentation: Microsoft Learn's own
/// C# sample uses <c>MemoryMB</c>, <c>CmdLine</c> and <c>DeleteContainerFlags</c>, none of which
/// exist in the shipped assembly. Re-derive with <c>windows/spikes/WslcApiDump</c> after any
/// version bump — that is now its whole job.
///
/// Everything the SDK is aliased as <c>Sdk</c> deliberately: <c>ImageInfo</c>,
/// <c>ContainerInfo</c>, <c>ProcessSettings</c> and <c>Signal</c> all exist BOTH here and in the
/// parent <c>NucleicBroker</c> namespace, and an unqualified <c>using</c> makes each one
/// ambiguous at the point of use.
///
/// **What this facade cannot do, and why that is reported rather than hidden.** The SDK projects
/// <c>WSLCCompat.idl</c>, the deliberately-stable SDK-facing COM surface, and that surface
/// genuinely lacks container enumeration, per-container stats, pty/resize, and any way to
/// re-adopt a running session or container. Two consequences run through this whole file:
///
/// 1. <c>Sdk.Container</c> has **no Name property** and <c>Sdk.Session</c> has no
///    <c>GetContainers()</c>, so a container is reachable only through the handle
///    <c>CreateContainer</c> returned. This class therefore keeps its own name→handle roster,
///    and that roster — not the service — is what <c>container.list</c> answers from.
/// 2. The roster dies with the process. A restarted broker cannot see, address or re-adopt
///    anything it created before, and <c>session.ensure</c> fails
///    <see cref="WslcError.SessionExists"/> because the compat <c>Start()</c> refuses a name that
///    is already running (confirmed on hardware, §13.1). That is the D13 reattach gap.
///
/// Both are declared through <see cref="Capabilities"/> so hostd degrades from the `hello`
/// exchange instead of discovering them at a call site. Closing them needs the service-internal
/// COM interface; see docs/WINDOWS_PORT.md §13.1 for what that costs and what still gates it.
///
///   WslcService (components) → Session (VM host, images) → Container → Process.
/// </summary>
public sealed class WslcFacade : IWslc
{
    private IBrokerEvents? events;
    private Sdk.Session? session;
    private string? gateway;
    private readonly SemaphoreSlim sessionGate = new(1, 1);

    /// D13's Tier 1 internal-COM arm, or null where it could not bind. Only used to recover an
    /// orphaned session after a broker restart; every other call rides the compat SDK.
    private readonly WslcInternal? recovery = WslcInternal.TryBind();

    /// <summary>
    /// Construct the facade with COM security configured first.
    ///
    /// The ordering is load-bearing and easy to lose: `CoInitializeSecurity` must precede the
    /// **first COM call in the process**, and the compat SDK makes its own. Doing it in a factory
    /// keeps that constraint next to the code that depends on it rather than in `Program.cs`,
    /// where a later reorder would silently break session recovery with a `0x80070542` that reads
    /// as "not found".
    /// </summary>
    public static WslcFacade Create()
    {
        WslcInternal.InitializeSecurity();
        return new WslcFacade();
    }

    /// name → the handle CreateContainer returned. See the class remarks: without this there is
    /// no way to address a container at all, because Sdk.Container carries no name.
    private readonly Dictionary<string, Entry> containers = [];
    private readonly Lock containersLock = new();

    /// <summary>
    /// A container handle plus what we have learned about it. <c>SetprivWorks</c> is resolved
    /// lazily on the first uid-dropping exec and then cached — see
    /// <see cref="ResolvePrivilegeDropAsync"/>.
    /// </summary>
    private sealed class Entry(Sdk.Container container, string image)
    {
        internal Sdk.Container Container { get; } = container;
        internal string Image { get; } = image;
        internal bool? SetprivWorks { get; set; }
    }

    public string? WslcVersion
    {
        get
        {
            try
            {
                var version = Sdk.WslcService.GetVersion();
                // The projection doesn't override ToString(), so the default renders the type
                // name — format the triple by hand or `hello` reports a class name as a version.
                return $"{version.Major}.{version.Minor}.{version.Revision}";
            }
            catch (Exception)
            {
                // A missing/too-old WSL throws here. That is an onboarding condition (§8), not a
                // broker failure: null is exactly the "wslc absent" signal `hello` is meant to
                // carry, and components.missing explains it properly.
                return null;
            }
        }
    }

    /// <summary>
    /// Stats are always offered — not from the SDK (there is no `GetStatistics()`) but from an
    /// in-guest cgroup read, which is indistinguishable to hostd. `recover` is added when D13's
    /// Tier 1 arm bound: a broker restart re-adopts and clears its orphaned session instead of
    /// leaving the user a sandbox only `wsl --shutdown` can fix.
    ///
    /// Still absent, and deliberately: `enumerate` (`container.list` answers from this broker's
    /// own roster, not the service), `reattach` (containers do not survive a restart — Tier 2,
    /// blocked, §13.2) and `tty` (the compat surface has no pty).
    /// </summary>
    public IReadOnlyList<string> Capabilities =>
        recovery is not null ? ["stats", "recover"] : ["stats"];

    public void SetEvents(IBrokerEvents events) => this.events = events;

    // MARK: - Components (§8 onboarding)

    public Task<IReadOnlyList<string>> MissingComponentsAsync(CancellationToken ct)
    {
        // A LIST of Component, not a flags enum — and it answers from OS feature state, so it
        // works even when the service class isn't registered. That makes it the one call that
        // explains every other failure.
        var missing = Sdk.WslcService.GetMissingComponents().Select(c => c.ToString()).ToList();
        return Task.FromResult<IReadOnlyList<string>>(missing);
    }

    public async Task InstallComponentsAsync(CancellationToken ct)
    {
        var operation = Sdk.WslcService.InstallWithDependenciesAsync();
        operation.Progress = (_, progress) => events?.InstallProgress(
            progress.Component.ToString(),
            progress.Total == 0 ? 0 : 100.0 * progress.Progress / progress.Total);
        await operation.AsTask(ct).ConfigureAwait(false);
        events?.InstallProgress("done", 100);
    }

    // MARK: - Session

    public async Task<string> EnsureSessionAsync(SessionSpec spec, CancellationToken ct)
    {
        await sessionGate.WaitAsync(ct).ConfigureAwait(false);
        try
        {
            if (session is not null) return gateway!;

            var created = NewSession(spec);
            try
            {
                created.Start();
            }
            catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists)
            {
                // The constructor is lazy — it only captures settings — so reaching this means a
                // session of this name is genuinely RUNNING, started by a previous broker that
                // died. The compat surface cannot re-adopt it, so recovery goes through D13's
                // internal-COM arm: open it, note what it was running, terminate it, retry.
                created.Dispose();
                created = await RecoverAndRestartAsync(spec, ct).ConfigureAwait(false);
            }
            catch (Exception e) when (e is not WslcError)
            {
                created.Dispose();
                throw Translate(e, WslcError.StartFailed);
            }

            session = created;
            gateway = await ResolveGatewayAsync(ct).ConfigureAwait(false);
            return gateway;
        }
        finally
        {
            sessionGate.Release();
        }
    }

    /// <summary>A settings-configured session with its handlers already attached. Subscribing
    /// must happen BEFORE `Start()`, or a session that dies during boot never reports down.</summary>
    private Sdk.Session NewSession(SessionSpec spec)
    {
        var settings = new Sdk.SessionSettings(spec.Name, spec.DataDir);
        if (spec.Cpu is { } cpu) settings.CpuCount = (uint)cpu;
        if (spec.MemoryMB is { } memory) settings.MemorySizeInMB = (uint)memory;

        var created = new Sdk.Session(settings);
        created.Terminated += reason => events?.SessionDown(reason.ToString());
        // Not surfaced as an RPC, but it is the only crash detail wslc offers and it is what
        // makes a SIGKILLed agent explicable in the host log (the Swift `diagnoseKill` seam).
        created.ProcessCrashed += crash => Console.Error.WriteLine(
            $"wslc: process {crash.ProcessName} (pid {crash.Pid}) crashed with signal "
            + $"{crash.Signal}; dump at {crash.DumpPath}");
        return created;
    }

    /// <summary>
    /// A session of this name is already running and we do not own it — the signature of a broker
    /// that died with its sandbox up (docs/WINDOWS_PORT.md §13.2, D13 Tier 1).
    ///
    /// Clear it through the internal COM arm and start fresh. The orphan's containers are lost,
    /// which is the deliberate Tier 1 trade: they are lost today too (nothing could reach that
    /// session at all), `ContainerManager.reconcile` already copes with an empty sandbox, and the
    /// alternative — keeping them alive — is Tier 2 and blocked. What this buys is that a broker
    /// restart stops requiring the user to run `wsl --shutdown` by hand.
    /// </summary>
    private async Task<Sdk.Session> RecoverAndRestartAsync(SessionSpec spec, CancellationToken ct)
    {
        if (recovery is null)
            throw new WslcError(
                WslcError.SessionExists,
                $"a wslc session named '{spec.Name}' is already running, the compat SDK cannot "
                + "re-adopt it, and the internal COM interface did not bind; run `wsl --shutdown`");

        if (recovery.RecoverSession(spec.Name) is null)
            throw new WslcError(
                WslcError.SessionExists,
                $"a wslc session named '{spec.Name}' is already running and could not be "
                + "recovered; run `wsl --shutdown` to clear it");

        // Terminate() returns before the VM is gone — the service tears it down asynchronously —
        // so the next Start() can still see the old name. Retry rather than reporting a failure
        // that a second attempt a moment later would not have hit.
        for (var attempt = 0; ; attempt++)
        {
            await Task.Delay(500, ct).ConfigureAwait(false);
            var retry = NewSession(spec);
            try
            {
                retry.Start();
                Console.Error.WriteLine($"wslc: session '{spec.Name}' restarted after recovery");
                return retry;
            }
            catch (Exception e) when (HResultOf(e) == ErrorAlreadyExists && attempt < 20)
            {
                retry.Dispose();
            }
            catch (Exception e)
            {
                retry.Dispose();
                throw e is WslcError ? e : Translate(e, WslcError.StartFailed);
            }
        }
    }

    public Task TerminateSessionAsync(CancellationToken ct)
    {
        lock (containersLock)
        {
            foreach (var entry in containers.Values) entry.Container.Dispose();
            containers.Clear();
        }
        session?.Terminate();
        session?.Dispose();
        session = null;
        gateway = null;
        return Task.CompletedTask;
    }

    /// <summary>
    /// The address a guest reaches the host on (§5). **No wslc API surfaces it** — there is no
    /// gateway property anywhere on Session or Container, and ContainerPortMapping is inbound
    /// host→guest, the wrong direction. So it comes from Windows networking instead: the IPv4
    /// address the host holds on the WSL vSwitch.
    ///
    /// Polled, because the vNIC appears as the VM boots and Start() returning does not mean it
    /// is up yet.
    /// </summary>
    private static async Task<string> ResolveGatewayAsync(CancellationToken ct)
    {
        for (var attempt = 0; ; attempt++)
        {
            if (FindWslAdapterAddress() is { } address) return address;
            if (attempt >= 20)
                throw new WslcError(
                    WslcError.StartFailed,
                    "no 'vEthernet (WSL)' adapter address after session start — the guest has no "
                    + "route to the host control plane");
            await Task.Delay(250, ct).ConfigureAwait(false);
        }
    }

    private static string? FindWslAdapterAddress() =>
        NetworkInterface.GetAllNetworkInterfaces()
            .Where(nic => nic.OperationalStatus == OperationalStatus.Up)
            .Where(nic => nic.Name.Contains("WSL", StringComparison.OrdinalIgnoreCase)
                || nic.Description.Contains("WSL", StringComparison.OrdinalIgnoreCase))
            .SelectMany(nic => nic.GetIPProperties().UnicastAddresses)
            .Select(unicast => unicast.Address)
            .Where(address => address.AddressFamily == AddressFamily.InterNetwork)
            .Select(address => address.ToString())
            .FirstOrDefault();

    private Sdk.Session RequireSession() =>
        session ?? throw new WslcError(
            WslcError.NotRunning, "no wslc session (call session.ensure first)");

    // MARK: - Images

    public async Task PullImageAsync(string reference, RegistryAuth? auth, CancellationToken ct)
    {
        var current = RequireSession();
        var options = new Sdk.PullImageOptions(reference);

        // RegistryAuth is a plain STRING, not a credentials object — and the string is minted by
        // Session.Authenticate against the registry the ref names. GHCR reads a PAT with
        // read:packages as the password (ContainerEngine.registryAuth(for:) does the same).
        if (auth is { Username: { } username, Password: { } password })
        {
            try
            {
                options.RegistryAuth = current.Authenticate(
                    RegistryUri(reference), username, password);
            }
            catch (Exception e) when (e is not WslcError)
            {
                throw Translate(e, WslcError.PullFailed);
            }
        }

        try
        {
            var operation = current.PullImageAsync(options);
            // Progress rides the ASYNC overload only; the sync PullImage reports nothing. This is
            // what feeds the existing controlDownloadProgress UI surface.
            operation.Progress = (_, progress) => events?.PullProgress(
                reference,
                progress.Status.ToString(),
                (long)progress.CurrentBytes,
                (long)progress.TotalBytes);
            await operation.AsTask(ct).ConfigureAwait(false);
        }
        catch (Exception e) when (e is not WslcError && e is not OperationCanceledException)
        {
            throw Translate(e, WslcError.PullFailed);
        }
    }

    /// <summary>`ghcr.io/abkslm/naros-agent:26.07` → `https://ghcr.io`. A ref whose first segment
    /// carries no dot or port has no registry host at all (`ubuntu:24.04`), which means Docker
    /// Hub.</summary>
    private static Uri RegistryUri(string reference)
    {
        var firstSegment = reference.Split('/')[0];
        var isHost = firstSegment.Contains('.') || firstSegment.Contains(':')
            || firstSegment == "localhost";
        return new Uri($"https://{(isHost ? firstSegment : "index.docker.io")}");
    }

    public Task<IReadOnlyList<ImageInfo>> ListImagesAsync(CancellationToken ct) =>
        Task.FromResult<IReadOnlyList<ImageInfo>>(
            RequireSession().GetImages().Select(Describe).ToList());

    public Task DeleteImageAsync(string reference, CancellationToken ct)
    {
        RequireSession().DeleteImage(reference);
        return Task.CompletedTask;
    }

    public Task<ImageInfo?> InspectImageAsync(string reference, CancellationToken ct)
    {
        var image = RequireSession().GetImages().FirstOrDefault(i => i.Name == reference);
        return Task.FromResult(image is null ? null : Describe(image));
    }

    /// `.Name`/`.Sha256`/`.Size` — not the `.Reference`/`.Digest` the docs show. Sha256 is an
    /// IBuffer of raw bytes, so it becomes the `sha256:<hex>` digest the rest of Nucleic speaks.
    private static ImageInfo Describe(Sdk.ImageInfo image) =>
        new(image.Name, HexDigest(image.Sha256), (long)image.Size);

    private static string? HexDigest(IBuffer? buffer)
    {
        if (buffer is null || buffer.Length == 0) return null;
        var bytes = new byte[buffer.Length];
        DataReader.FromBuffer(buffer).ReadBytes(bytes);
        return $"sha256:{Convert.ToHexString(bytes).ToLowerInvariant()}";
    }

    // MARK: - Containers

    public Task CreateContainerAsync(ContainerCreateSpec spec, CancellationToken ct)
    {
        var settings = new Sdk.ContainerSettings(spec.Image) { Name = spec.Name };
        if (spec.Hostname is { } hostname) settings.HostName = hostname;   // capital N
        if (spec.NetworkingMode is { } mode)
            settings.NetworkingMode = Enum.Parse<Sdk.ContainerNetworkingMode>(mode, ignoreCase: true);
        if (spec.Volumes is { Count: > 0 } volumes)
            settings.Volumes = volumes
                .Select(v => new Sdk.ContainerVolume(v.Host, v.Guest, v.ReadOnly))
                .ToList();

        // env and initArgv both live on InitProcess, so build it when EITHER is present —
        // attaching env to a null InitProcess silently dropped the whole environment before.
        if (spec.InitArgv is { Count: > 0 } || spec.Env is { Count: > 0 })
        {
            var init = new Sdk.ProcessSettings();
            if (spec.InitArgv is { Count: > 0 } argv) init.CommandLine = argv.ToList();
            if (spec.Env is { Count: > 0 } env) init.EnvironmentVariables = new Dictionary<string, string>(env);
            settings.InitProcess = init;
        }

        try
        {
            var container = RequireSession().CreateContainer(settings);
            lock (containersLock)
            {
                if (containers.Remove(spec.Name, out var stale)) stale.Container.Dispose();
                containers[spec.Name] = new Entry(container, spec.Image);
            }
        }
        catch (Exception e) when (e is not WslcError)
        {
            throw Translate(e, WslcError.StartFailed);
        }
        return Task.CompletedTask;
    }

    private Sdk.Container RequireContainer(string name)
    {
        lock (containersLock)
        {
            return containers.TryGetValue(name, out var entry)
                ? entry.Container
                // Not necessarily absent from the SERVICE — absent from this broker's roster,
                // which after a restart is everything it ever created. See the class remarks.
                : throw new WslcError(WslcError.NotFound, $"no container named {name}");
        }
    }

    public Task StartContainerAsync(string name, CancellationToken ct)
    {
        try
        {
            RequireContainer(name).Start();
        }
        catch (Exception e) when (e is not WslcError)
        {
            throw Translate(e, WslcError.StartFailed);
        }
        return Task.CompletedTask;
    }

    public Task StopContainerAsync(string name, int signal, int graceMs, CancellationToken ct)
    {
        RequireContainer(name).Stop(MapSignal(signal), TimeSpan.FromMilliseconds(graceMs));
        return Task.CompletedTask;
    }

    public Task DeleteContainerAsync(string name, bool force, CancellationToken ct)
    {
        var container = RequireContainer(name);
        container.Delete(force ? Sdk.DeleteContainerOption.Force : Sdk.DeleteContainerOption.None);
        lock (containersLock)
        {
            if (containers.Remove(name, out var entry)) entry.Container.Dispose();
        }
        return Task.CompletedTask;
    }

    /// <summary>
    /// The broker's OWN roster, not the service's. The compat SDK has no enumeration call at all,
    /// so this cannot see a container this process did not create — which is why `enumerate` is
    /// absent from <see cref="Capabilities"/> and why §2.3's post-restart reconcile is a known
    /// gap rather than a silently empty answer.
    /// </summary>
    public Task<IReadOnlyList<ContainerInfo>> ListContainersAsync(CancellationToken ct)
    {
        lock (containersLock)
        {
            return Task.FromResult<IReadOnlyList<ContainerInfo>>(
                containers
                    .Select(kv => new ContainerInfo(kv.Key, kv.Value.Image, Describe(kv.Value.Container.State)))
                    .ToList());
        }
    }

    public Task<string> ContainerStateAsync(string name, CancellationToken ct)
    {
        lock (containersLock)
        {
            return Task.FromResult(containers.TryGetValue(name, out var entry)
                ? Describe(entry.Container.State)
                : "absent");
        }
    }

    /// ContainerState is Invalid|Created|Running|Exited|Deleted; the Swift policy layer only
    /// distinguishes running/stopped/absent, and a Deleted handle is absent as far as it cares.
    private static string Describe(Sdk.ContainerState state) => state switch
    {
        Sdk.ContainerState.Running => "running",
        Sdk.ContainerState.Deleted => "absent",
        _ => "stopped",
    };

    /// <summary>
    /// There is no <c>GetStatistics()</c> on the compat surface — <c>Container</c> offers only
    /// <c>Id</c>, <c>State</c>, <c>InitProcess</c> and <c>Inspect()</c>. So the counters come from
    /// the guest's own cgroup v2 files, read the way a Linux-native engine would (§13.1 finding 2).
    ///
    /// Deliberately `cat` and `echo` only: no awk, no sed. This runs in whatever image the user
    /// configured, and nash is `/bin/sh` in narOS — the smaller the tool surface, the fewer images
    /// this silently fails in.
    /// </summary>
    public async Task<ContainerStatsInfo?> ContainerStatsAsync(string name, CancellationToken ct)
    {
        var container = RequireContainer(name);
        if (container.State != Sdk.ContainerState.Running) return null;

        const string script =
            "cat /sys/fs/cgroup/cpu.stat 2>/dev/null; echo ---; "
            + "cat /sys/fs/cgroup/memory.current 2>/dev/null; echo ---; "
            + "cat /sys/fs/cgroup/memory.max 2>/dev/null; echo ---; "
            + "cat /sys/fs/cgroup/memory.events 2>/dev/null";

        var (exitCode, output) = await RunCapturingAsync(
            container, ["/bin/sh", "-c", script], TimeSpan.FromSeconds(10), ct).ConfigureAwait(false);
        if (exitCode != 0) return null;

        var sections = output.Split("---", StringSplitOptions.TrimEntries);
        if (sections.Length < 4) return null;

        return new ContainerStatsInfo(
            CpuUsageUsec: FieldValue(sections[0], "usage_usec") ?? 0,
            MemoryUsedBytes: Number(sections[1]) ?? 0,
            // `memory.max` reads the literal "max" when the cgroup is unlimited — not a number.
            // -1 rather than 0 so "unlimited" stays distinguishable on the wire; the Swift
            // consumer clamps with max(0,…) today, which is its existing "unknown".
            MemoryLimitBytes: Number(sections[2]) ?? -1,
            OomKills: FieldValue(sections[3], "oom_kill"));
    }

    /// A cgroup "flat keyed" file: `key value` per line.
    private static long? FieldValue(string section, string key)
    {
        foreach (var line in section.Split('\n', StringSplitOptions.TrimEntries))
        {
            var parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);
            if (parts.Length == 2 && parts[0] == key && long.TryParse(parts[1], out var value))
                return value;
        }
        return null;
    }

    private static long? Number(string section) =>
        long.TryParse(section.Trim(), out var value) ? value : null;

    // MARK: - Processes

    public async Task<IWslcProcess> ExecAsync(long procId, ProcSpec spec, CancellationToken ct)
    {
        if (spec.Tty)
            // Not a failure to retry: ProcessSettings has no Terminal and Process has no resize.
            // §7's Terminal panel needs the internal COM interface (IWSLCProcess::ResizeTty).
            throw new WslcError(
                WslcError.Unsupported,
                "the wslc compat SDK cannot allocate a pty; proc.exec(tty:true) is unavailable");

        var container = RequireContainer(spec.Container);
        var settings = new Sdk.ProcessSettings
        {
            CommandLine = (await WithPrivilegeDropAsync(spec, ct).ConfigureAwait(false)).ToList(),
            OutputMode = Sdk.ProcessOutputMode.Event,
        };
        if (spec.Cwd is { } cwd) settings.WorkingDirectory = cwd;
        if (spec.Env is { Count: > 0 } env)
            settings.EnvironmentVariables = new Dictionary<string, string>(env);  // not Environment

        Sdk.Process process;
        try
        {
            // CreateProcess then Start() — two steps, and the split is the point: handlers are
            // attached in between, so the first output chunk cannot be missed.
            process = container.CreateProcess(settings);
        }
        catch (Exception e) when (e is not WslcError)
        {
            throw Translate(e, WslcError.StartFailed);
        }

        // OutputReceived and ErrorReceived are SEPARATE events, each carrying only bytes — there
        // is no stderr flag to read, so the stream is decided by which handler fired.
        process.OutputReceived += data => events?.ProcOutput(procId, stderr: false, data);
        process.ErrorReceived += data => events?.ProcOutput(procId, stderr: true, data);
        process.Exited += code => events?.ProcExited(procId, code);

        // NOT started here — BrokerService starts it after the `procId` response is on the wire.
        // See IWslcProcess.StartAsync for why that ordering is load-bearing.
        return new WslcProcess(process);
    }

    /// <summary>
    /// <c>ProcessSettings</c> has no <c>UserId</c>/<c>GroupId</c>, so dropping to the agent uid is
    /// done in-guest by wrapping argv — the fallback §3.2 always named, which costs nothing
    /// because the interceptors and nash never look at the numeric uid.
    ///
    /// argv is passed to `setpriv` directly rather than through a shell, so nothing here can be
    /// quoted wrong or injected into.
    /// </summary>
    private async Task<IReadOnlyList<string>> WithPrivilegeDropAsync(
        ProcSpec spec, CancellationToken ct)
    {
        if (spec.Uid is not { } uid || uid == 0) return spec.Argv;
        var gid = spec.Gid ?? uid;

        if (!await ResolvePrivilegeDropAsync(spec.Container, ct).ConfigureAwait(false))
            // Refuse rather than run the agent as root. This path exists because the alternative
            // is a silent privilege escalation: the caller asked for uid 501 and got 0, in the
            // one place the sandbox's user separation is enforced.
            throw new WslcError(
                WslcError.Unsupported,
                $"cannot drop to uid {uid} in this image: it has no util-linux `setpriv` "
                + "(BusyBox ships a `setpriv` that does not support --reuid). Use an image with "
                + "util-linux, as the narOS agent image does — refusing to run as root instead.");

        return
        [
            "setpriv", $"--reuid={uid}", $"--regid={gid}", "--init-groups", "--",
            .. spec.Argv,
        ];
    }

    /// <summary>
    /// Does this image have a `setpriv` that can actually change uid? Probed once per container
    /// and cached, because the answer is a property of the image and an extra exec per agent
    /// command would not be free.
    ///
    /// The probe is `setpriv --reuid=0 --regid=0 --init-groups -- true`: a no-op on util-linux,
    /// and an "unrecognized option" failure on BusyBox's namesake, which accepts only capability
    /// flags. Testing for the *binary* is not enough — BusyBox has one, it just cannot do this
    /// (observed on hardware, docs/WINDOWS_PORT.md §13.3).
    /// </summary>
    private async Task<bool> ResolvePrivilegeDropAsync(string name, CancellationToken ct)
    {
        Entry entry;
        lock (containersLock)
        {
            if (!containers.TryGetValue(name, out entry!))
                throw new WslcError(WslcError.NotFound, $"no container named {name}");
            if (entry.SetprivWorks is { } cached) return cached;
        }

        bool works;
        try
        {
            var (exitCode, _) = await RunCapturingAsync(
                entry.Container,
                ["setpriv", "--reuid=0", "--regid=0", "--init-groups", "--", "true"],
                TimeSpan.FromSeconds(15), ct).ConfigureAwait(false);
            works = exitCode == 0;
        }
        catch (Exception)
        {
            works = false;
        }

        lock (containersLock) entry.SetprivWorks = works;
        if (!works)
            Console.Error.WriteLine(
                $"wslc: container '{name}' has no usable setpriv — uid-dropping execs will be "
                + "refused rather than run as root");
        return works;
    }

    /// <summary>Run to completion and capture stdout+stderr. The in-guest half of what the macOS
    /// engine's `runCapturing` does, and the only way stats and shim re-seeding work without a
    /// second mechanism.</summary>
    private static async Task<(int ExitCode, string Output)> RunCapturingAsync(
        Sdk.Container container, IReadOnlyList<string> argv, TimeSpan timeout, CancellationToken ct)
    {
        var settings = new Sdk.ProcessSettings
        {
            CommandLine = argv.ToList(),
            OutputMode = Sdk.ProcessOutputMode.Event,
        };
        var buffer = new MemoryStream();
        var exited = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously);

        var process = container.CreateProcess(settings);
        try
        {
            process.OutputReceived += data => { lock (buffer) buffer.Write(data, 0, data.Length); };
            process.ErrorReceived += data => { lock (buffer) buffer.Write(data, 0, data.Length); };
            process.Exited += code => exited.TrySetResult(code);
            process.Start();

            int exitCode;
            try
            {
                exitCode = await exited.Task.WaitAsync(timeout, ct).ConfigureAwait(false);
            }
            catch (TimeoutException)
            {
                process.Signal(Sdk.Signal.SIGKILL);
                throw new WslcError(
                    WslcError.NotRunning, $"`{string.Join(' ', argv)}` timed out in the container");
            }

            // Exited and OutputReceived are independent event sources, so a final chunk can still
            // be in flight when the exit fires. Waiting a beat costs nothing here (this path is
            // never on the agent's hot stdio) and avoids truncating the last line.
            await Task.Delay(50, ct).ConfigureAwait(false);
            lock (buffer) return (exitCode, System.Text.Encoding.UTF8.GetString(buffer.ToArray()));
        }
        finally
        {
            process.Dispose();
        }
    }

    private sealed class WslcProcess(Sdk.Process process) : IWslcProcess
    {
        public Task StartAsync(CancellationToken ct)
        {
            try
            {
                process.Start();
            }
            catch (Exception e) when (e is not WslcError)
            {
                process.Dispose();
                throw Translate(e, WslcError.StartFailed);
            }
            return Task.CompletedTask;
        }

        // stdin is a WinRT stream, not a WriteStdin call, and DataWriter is how you put bytes
        // into an IOutputStream. Held for the process lifetime and guarded, because hostd may
        // pipeline proc.stdin writes and StoreAsync is not reentrant.
        private readonly SemaphoreSlim stdinGate = new(1, 1);
        private IOutputStream? stdin;
        private DataWriter? writer;

        public async Task WriteStdinAsync(ReadOnlyMemory<byte> data, CancellationToken ct)
        {
            await stdinGate.WaitAsync(ct).ConfigureAwait(false);
            try
            {
                if (writer is null)
                {
                    stdin = process.GetInputStream();
                    writer = new DataWriter(stdin);
                }
                writer.WriteBytes(data.ToArray());
                await writer.StoreAsync().AsTask(ct).ConfigureAwait(false);
                await writer.FlushAsync().AsTask(ct).ConfigureAwait(false);
            }
            finally
            {
                stdinGate.Release();
            }
        }

        public async Task CloseStdinAsync(CancellationToken ct)
        {
            await stdinGate.WaitAsync(ct).ConfigureAwait(false);
            try
            {
                // Detach before disposing the writer, or the writer takes the stream down with it
                // and the close races the last StoreAsync. Closing the STREAM is what the guest
                // observes as EOF on stdin.
                writer?.DetachStream();
                writer?.Dispose();
                writer = null;
                stdin?.Dispose();
                stdin = null;
            }
            finally
            {
                stdinGate.Release();
            }
        }

        public Task SignalAsync(int signal, CancellationToken ct)
        {
            process.Signal(MapSignal(signal));
            return Task.CompletedTask;
        }

        public Task ResizeAsync(int cols, int rows, CancellationToken ct) =>
            Task.FromException(new WslcError(
                WslcError.Unsupported,
                "the wslc compat SDK has no pty, so there is no terminal to resize"));
    }

    // MARK: - Error translation

    /// <summary>
    /// POSIX int → the named <c>Signal</c> enum. The RPC carries POSIX numbers because every
    /// caller above it is platform-neutral, but wslc accepts only six, so anything else (SIGUSR1,
    /// SIGWINCH) is a permanent gap rather than a number to pass through.
    /// </summary>
    private static Sdk.Signal MapSignal(int signal) => signal switch
    {
        0 => Sdk.Signal.None,
        1 => Sdk.Signal.SIGHUP,
        2 => Sdk.Signal.SIGINT,
        3 => Sdk.Signal.SIGQUIT,
        9 => Sdk.Signal.SIGKILL,
        15 => Sdk.Signal.SIGTERM,
        _ => throw new WslcError(
            WslcError.Unsupported,
            $"wslc cannot deliver signal {signal} (only HUP, INT, QUIT, KILL and TERM)"),
    };

    private const int ErrorAlreadyExists = unchecked((int)0x800700B7);

    private static int HResultOf(Exception e) => e is COMException com ? com.HResult : e.HResult;

    /// <summary>
    /// COM HRESULTs → the RPC's `data.kind`. Keyed on the number, never the message: these
    /// exceptions frequently arrive with an EMPTY message, so string matching would silently
    /// classify every one of them as the fallback.
    /// </summary>
    private static WslcError Translate(Exception e, string fallbackKind)
    {
        var code = HResultOf(e);
        var kind = (uint)code switch
        {
            0x80040601 => WslcError.NotFound,      // WSLC_E_IMAGE_NOT_FOUND
            0x80040603 => WslcError.NotFound,      // WSLC_E_CONTAINER_NOT_FOUND
            0x80040605 => WslcError.NotRunning,    // WSLC_E_CONTAINER_NOT_RUNNING
            0x8004060F => WslcError.NotFound,      // WSLC_E_SESSION_NOT_FOUND
            0x80040607 => WslcError.SessionExists, // WSLC_E_SESSION_RESERVED
            // ERROR_ALREADY_EXISTS is CONTEXT-FREE: wslc returns it for a session name conflict
            // AND a container name conflict. It used to map to session_exists here, which made a
            // stale container report itself as a stuck session — a wrong diagnosis with a wrong
            // remedy (`wsl --shutdown` instead of removing one container). The session paths catch
            // this code by number before reaching Translate, so anything arriving here is the
            // other kind.
            0x800700B7 => WslcError.AlreadyExists, // ERROR_ALREADY_EXISTS (container name in use)
            // Nothing installed vs. installed-but-too-old: opposite diagnoses, and both mean the
            // sandbox is unusable rather than this call being wrong.
            0x80040154 => WslcError.Unavailable,   // REGDB_E_CLASSNOTREG
            0x80070032 => WslcError.Unavailable,   // ERROR_NOT_SUPPORTED
            0x8004060B => WslcError.Unavailable,   // WSLC_E_SDK_UPDATE_NEEDED
            0x8004060D => WslcError.PullFailed,    // WSLC_E_REGISTRY_BLOCKED_BY_POLICY
            _ => fallbackKind,
        };
        var message = string.IsNullOrWhiteSpace(e.Message) ? $"wslc failed (0x{code:X8})" : e.Message;
        return new WslcError(kind, message);
    }
}
#endif
