"""Frozen dataclasses for all configuration and result types."""

from __future__ import annotations

import enum
import os
import sys
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal, TypeAlias, TypedDict

# --------------------------------------------------------------------------------------------------
# Constants
# --------------------------------------------------------------------------------------------------

MiB: int = 1024 * 1024
GiB: int = 1024 * 1024 * 1024

# --------------------------------------------------------------------------------------------------
# Types: Enums
# --------------------------------------------------------------------------------------------------

if sys.version_info >= (3, 11):
    StrEnum = enum.StrEnum
else:

    class StrEnum(str, enum.Enum):
        """Backport of `enum.StrEnum` for Python 3.10.

        Matches `enum.StrEnum` semantics: `str()` and `format()` of a member
        return the member value.
        """

        def __str__(self) -> str:
            return str(self.value)


class PullPolicy(StrEnum):
    ALWAYS = "always"
    IF_MISSING = "if-missing"
    NEVER = "never"


class CpuPlacement(StrEnum):
    """Host placement policy for sandbox vCPU threads."""

    INHERIT = "inherit"
    AUTO = "auto"
    SPREAD = "spread"
    COMPACT = "compact"


class LogLevel(StrEnum):
    TRACE = "trace"
    DEBUG = "debug"
    INFO = "info"
    WARN = "warn"
    ERROR = "error"


class SecurityProfile(StrEnum):
    DEFAULT = "default"
    RESTRICTED = "restricted"


class DeploymentProfile(StrEnum):
    """Host-runtime isolation profile requested for local deployments."""

    SINGLE_TENANT = "single-tenant"
    MULTI_TENANT = "multi-tenant"


class SandboxStatus(StrEnum):
    CREATED = "created"
    STARTING = "starting"
    RUNNING = "running"
    STOPPED = "stopped"
    CRASHED = "crashed"
    DRAINING = "draining"
    PAUSED = "paused"


class BackendKind(StrEnum):
    LOCAL = "local"
    CLOUD = "cloud"


class ModificationPolicy(StrEnum):
    NO_RESTART = "no_restart"
    NEXT_START = "next_start"
    RESTART = "restart"


class PlannedChangeKind(StrEnum):
    CONFIG = "config"
    SECRET = "secret"


class ChangeKind(StrEnum):
    ADDED = "added"
    UPDATED = "updated"
    REMOVED = "removed"


class SecretChangeKind(StrEnum):
    ADDED = "added"
    ROTATED = "rotated"
    REMOVED = "removed"
    RENAMED = "renamed"
    HOSTS_UPDATED = "hosts updated"
    PLACEHOLDER_UPDATED = "placeholder updated"


class ModificationDisposition(StrEnum):
    LIVE = "live"
    NEXT_START = "next start"
    REQUIRES_RESTART = "requires restart"
    UNSUPPORTED = "unsupported"


class ResourceKind(StrEnum):
    CPUS = "cpus"
    MEMORY = "memory"


class ResourceConvergenceState(StrEnum):
    ACCEPTED = "accepted"
    CONVERGING = "converging"
    APPLIED = "applied"
    GUEST_REFUSED = "guest-refused"
    FAILED = "failed"


class StdinMode(StrEnum):
    NULL = "null"
    PIPE = "pipe"
    BYTES = "bytes"


class ExecEventType(StrEnum):
    STARTED = "started"
    STDOUT = "stdout"
    STDERR = "stderr"
    EXITED = "exited"
    FAILED = "failed"
    STDIN_ERROR = "stdin_error"


class PullEventType(StrEnum):
    RESOLVING = "resolving"
    RESOLVED = "resolved"
    LAYER_DOWNLOAD_PROGRESS = "layer_download_progress"
    LAYER_DOWNLOAD_COMPLETE = "layer_download_complete"
    LAYER_DOWNLOAD_VERIFYING = "layer_download_verifying"
    LAYER_MATERIALIZE_STARTED = "layer_materialize_started"
    LAYER_MATERIALIZE_PROGRESS = "layer_materialize_progress"
    LAYER_MATERIALIZE_WRITING = "layer_materialize_writing"
    LAYER_MATERIALIZE_COMPLETE = "layer_materialize_complete"
    STITCH_MERGING_TREES = "stitch_merging_trees"
    STITCH_WRITING_FSMETA = "stitch_writing_fsmeta"
    STITCH_WRITING_VMDK = "stitch_writing_vmdk"
    STITCH_COMPLETE = "stitch_complete"
    COMPLETE = "complete"


class Action(StrEnum):
    ALLOW = "allow"
    DENY = "deny"


class Direction(StrEnum):
    EGRESS = "egress"
    INGRESS = "ingress"
    ANY = "any"


class Protocol(StrEnum):
    TCP = "tcp"
    UDP = "udp"
    ICMPV4 = "icmpv4"
    ICMPV6 = "icmpv6"


class PortProtocol(StrEnum):
    TCP = "tcp"
    UDP = "udp"


class VsockSocketType(StrEnum):
    STREAM = "stream"
    DGRAM = "dgram"


class DestGroup(StrEnum):
    PUBLIC = "public"
    LOOPBACK = "loopback"
    PRIVATE = "private"
    LINK_LOCAL = "link-local"
    METADATA = "metadata"
    MULTICAST = "multicast"
    HOST = "host"


class NetworkProfile(StrEnum):
    """Composable high-level network access profile."""

    PUBLIC = "public"
    PRIVATE = "private"
    HOST = "host"


class ViolationAction(StrEnum):
    BLOCK = "block"
    BLOCK_AND_LOG = "block-and-log"
    BLOCK_AND_TERMINATE = "block-and-terminate"
    PASSTHROUGH = "passthrough"


@dataclass(frozen=True, slots=True)
class ViolationPolicy:
    """Secret violation behavior, including optional passthrough hosts."""

    fallback: ViolationAction = ViolationAction.BLOCK_AND_LOG
    passthrough_hosts: tuple[str, ...] = ()
    passthrough_host_patterns: tuple[str, ...] = ()
    passthrough_all_hosts: bool = False

    @classmethod
    def block(cls) -> ViolationPolicy:
        return cls(fallback=ViolationAction.BLOCK)

    @classmethod
    def block_and_log(cls) -> ViolationPolicy:
        return cls(fallback=ViolationAction.BLOCK_AND_LOG)

    @classmethod
    def block_and_terminate(cls) -> ViolationPolicy:
        return cls(fallback=ViolationAction.BLOCK_AND_TERMINATE)

    @classmethod
    def passthrough(
        cls,
        *,
        hosts: Sequence[str] = (),
        host_patterns: Sequence[str] = (),
        all_hosts: bool = False,
    ) -> ViolationPolicy:
        return cls(
            passthrough_hosts=tuple(hosts),
            passthrough_host_patterns=tuple(host_patterns),
            passthrough_all_hosts=all_hosts,
        )

    def _to_dict(self) -> ViolationAction | dict:
        # Validate the fallback even when passthrough is selected. Ignoring a
        # malformed enum on one serialization branch would make the public
        # type boundary depend on unrelated host-list fields.
        _enum_value(self.fallback, ViolationAction, "ViolationPolicy.fallback")
        if (
            not self.passthrough_hosts
            and not self.passthrough_host_patterns
            and not self.passthrough_all_hosts
        ):
            return self.fallback

        passthrough: dict = {}
        if self.passthrough_hosts:
            passthrough["hosts"] = list(self.passthrough_hosts)
        if self.passthrough_host_patterns:
            passthrough["host_patterns"] = list(self.passthrough_host_patterns)
        if self.passthrough_all_hosts:
            passthrough["all_hosts"] = True
        return {"passthrough": passthrough}


class MountKind(StrEnum):
    BIND = "bind"
    NAMED = "named"
    TMPFS = "tmpfs"
    DISK = "disk"


class StatVirtualization(StrEnum):
    """Per-mount stat-virtualization policy for virtiofs-backed mounts."""

    STRICT = "strict"
    RELAXED = "relaxed"
    OFF = "off"


class HostPermissions(StrEnum):
    """Per-mount host-permission policy for virtiofs-backed mounts."""

    PRIVATE = "private"
    MIRROR = "mirror"


class FsEntryKind(StrEnum):
    FILE = "file"
    DIRECTORY = "directory"
    SYMLINK = "symlink"
    OTHER = "other"


class DiskImageFormat(StrEnum):
    QCOW2 = "qcow2"
    RAW = "raw"
    VMDK = "vmdk"

class VolumeKind(StrEnum):
    DIRECTORY = "dir"
    DISK = "disk"


class NamedVolumeMode(StrEnum):
    EXISTING = "existing"
    CREATE = "create"
    ENSURE_EXISTS = "ensure-exists"


class ImageArchiveFormat(StrEnum):
    DOCKER = "docker"
    OCI = "oci"


class RootDiskKind(StrEnum):
    MANAGED = "managed"
    TMPFS = "tmpfs"
    DISK_IMAGE = "disk-image"
    FLAT = "flat"


class FlatClone(StrEnum):
    AUTO = "auto"
    COPY = "copy"
    REFLINK = "reflink"


class ImageSourceKind(StrEnum):
    OCI = "oci"
    BIND = "bind"
    DISK = "disk"


class PatchKind(StrEnum):
    TEXT = "text"
    FILE = "file"
    MKDIR = "mkdir"
    APPEND = "append"
    COPY_FILE = "copy_file"
    COPY_DIR = "copy_dir"
    SYMLINK = "symlink"
    REMOVE = "remove"


class NetworkDestinationKind(StrEnum):
    ANY = "any"
    IP = "ip"
    CIDR = "cidr"
    DOMAIN = "domain"
    DOMAIN_SUFFIX = "domain_suffix"
    GROUP = "group"


class SnapshotStateKind(StrEnum):
    FILE = "file"
    CHECKPOINT = "checkpoint"


class SnapshotFormat(StrEnum):
    RAW = "raw"
    QCOW2 = "qcow2"


class SnapshotScope(StrEnum):
    DISK = "disk"
    RESUMABLE = "resumable"

class RlimitResource(StrEnum):
    CPU = "cpu"
    FSIZE = "fsize"
    DATA = "data"
    STACK = "stack"
    CORE = "core"
    RSS = "rss"
    NPROC = "nproc"
    NOFILE = "nofile"
    MEMLOCK = "memlock"
    AS = "as"
    LOCKS = "locks"
    SIGPENDING = "sigpending"
    MSGQUEUE = "msgqueue"
    NICE = "nice"
    RTPRIO = "rtprio"
    RTTIME = "rttime"


class LogSource(StrEnum):
    STDOUT = "stdout"
    STDERR = "stderr"
    OUTPUT = "output"
    SYSTEM = "system"


class LogReadSource(StrEnum):
    STDOUT = "stdout"
    STDERR = "stderr"
    OUTPUT = "output"
    SYSTEM = "system"
    ALL = "all"


# --------------------------------------------------------------------------------------------------
# Types: API Contracts
# --------------------------------------------------------------------------------------------------


class _InitOptionsRequired(TypedDict):
    cmd: str


class InitOptions(_InitOptionsRequired, total=False):
    """Mapping form accepted by the ``Sandbox.create(init=...)`` shorthand."""

    args: list[str]
    env: dict[str, str]


class SecretModifySpec(TypedDict, total=False):
    """Desired state for one secret passed to ``Sandbox.modify``.

    ``env``, ``value``, and ``store`` are mutually exclusive secret sources.
    """

    env: str
    value: str
    store: str
    placeholder: str
    allowed_hosts: list[str]


class ModificationConflict(TypedDict):
    """A conflict that prevents a sandbox modification from applying."""

    field: str
    message: str


class ModificationWarning(TypedDict):
    """A non-fatal warning about a requested sandbox modification."""

    field: str
    message: str


class ResourceResizeStatus(TypedDict):
    """Observed convergence state for one live resource resize."""

    resource: ResourceKind
    requested: str
    actual: str
    enforced: str
    state: ResourceConvergenceState


class _ConfigPlannedChangeRequired(TypedDict):
    kind: Literal[PlannedChangeKind.CONFIG]
    field: str
    change: ChangeKind
    disposition: ModificationDisposition


class ConfigPlannedChange(_ConfigPlannedChangeRequired, total=False):
    """One planned non-secret configuration change."""

    before: str
    after: str
    reason: str


class _SecretPlannedChangeRequired(TypedDict):
    kind: Literal[PlannedChangeKind.SECRET]
    field: str
    name: str
    change: SecretChangeKind
    disposition: ModificationDisposition


class SecretPlannedChange(_SecretPlannedChangeRequired, total=False):
    """One planned secret change with secret values omitted."""

    before_ref: str
    after_ref: str
    allow_hosts: list[str]
    reason: str


PlannedChange: TypeAlias = ConfigPlannedChange | SecretPlannedChange


class _SandboxModificationPlanRequired(TypedDict):
    sandbox: str
    status: SandboxStatus
    applied: bool
    policy: ModificationPolicy
    changes: list[PlannedChange]
    conflicts: list[ModificationConflict]
    warnings: list[ModificationWarning]


class SandboxModificationPlan(_SandboxModificationPlanRequired, total=False):
    """Typed result returned by sandbox modification operations."""

    resize_status: list[ResourceResizeStatus]


class ExecOptions(TypedDict, total=False):
    """Options accepted in the second positional argument to exec methods."""

    args: list[str]
    cwd: str
    user: str
    env: Mapping[str, str]
    timeout: float
    stdin: Stdin | bytes
    stdin_data: bytes
    tty: bool
    rlimits: list[Rlimit]


# --------------------------------------------------------------------------------------------------
# Types: Size
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class Size:
    """Memory/storage size value type."""

    bytes: int

    @classmethod
    def mib(cls, n: int) -> Size:
        return cls(n * MiB)

    @classmethod
    def gib(cls, n: int) -> Size:
        return cls(n * GiB)

    @property
    def mib_count(self) -> int:
        return self.bytes // MiB


# --------------------------------------------------------------------------------------------------
# Types: ExitStatus
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class ExitStatus:
    """Process exit status."""

    code: int
    success: bool


# --------------------------------------------------------------------------------------------------
# Types: Rlimit
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class Rlimit:
    """A POSIX resource limit."""

    resource: RlimitResource
    soft: int
    hard: int

    @classmethod
    def nofile(cls, limit: int) -> Rlimit:
        return cls(RlimitResource.NOFILE, limit, limit)

    @classmethod
    def cpu(cls, secs: int) -> Rlimit:
        return cls(RlimitResource.CPU, secs, secs)

    @classmethod
    def as_(cls, *, soft: int, hard: int) -> Rlimit:
        return cls(RlimitResource.AS, soft, hard)

    @classmethod
    def nproc(cls, limit: int) -> Rlimit:
        return cls(RlimitResource.NPROC, limit, limit)

    @classmethod
    def fsize(cls, limit: int) -> Rlimit:
        return cls(RlimitResource.FSIZE, limit, limit)

    @classmethod
    def memlock(cls, limit: int) -> Rlimit:
        return cls(RlimitResource.MEMLOCK, limit, limit)

    @classmethod
    def stack(cls, limit: int) -> Rlimit:
        return cls(RlimitResource.STACK, limit, limit)

    def _to_dict(self) -> dict:
        return {
            "resource": _enum_value(self.resource, RlimitResource, "Rlimit.resource"),
            "soft": self.soft,
            "hard": self.hard,
        }


# --------------------------------------------------------------------------------------------------
# Types: Stdin
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class Stdin:
    """Stdin mode for command execution."""

    _mode: StdinMode
    _data: bytes | None = None

    @classmethod
    def null(cls) -> Stdin:
        return cls(StdinMode.NULL)

    @classmethod
    def pipe(cls) -> Stdin:
        return cls(StdinMode.PIPE)

    @classmethod
    def bytes(cls, data: bytes) -> Stdin:
        return cls(StdinMode.BYTES, data)


# --------------------------------------------------------------------------------------------------
# Types: Init Handoff
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class InitConfig:
    """Guest init-handoff configuration.

    Pass to ``Sandbox.create(init=...)`` when the init binary takes
    argv or extra env vars. For the simple case, just pass the cmd
    as a bare string: ``init="auto"``.

    ``cmd`` is either an absolute path inside the guest rootfs or the
    literal ``"auto"``. Auto honors a known init at the start of the
    image ENTRYPOINT, preserves attached init-entrypoint commands, then
    probes /sbin/init, /lib/systemd/systemd, and /usr/lib/systemd/systemd
    inside the guest.
    """

    cmd: str
    args: tuple[str, ...] = ()
    env: Mapping[str, str] = field(default_factory=dict)

    def _to_dict(self) -> dict:
        d: dict = {"cmd": self.cmd}
        if self.args:
            d["args"] = list(self.args)
        if self.env:
            d["env"] = dict(self.env)
        return d


# --------------------------------------------------------------------------------------------------
# Types: Mount
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class MountConfig:
    """Volume mount configuration.

    ``stat_virtualization`` and ``host_permissions`` are only meaningful for
    virtiofs-backed mounts (``BIND`` and ``NAMED``). Setting either on a
    ``TMPFS`` or ``DISK`` mount raises ``ValueError`` at serialization time.
    """

    kind: MountKind
    bind: str | None = None
    named: str | None = None
    named_mode: NamedVolumeMode | None = None
    named_kind: VolumeKind | None = None
    quota_mib: int | None = None
    size_mib: int | None = None
    readonly: bool = False
    noexec: bool = False
    nosuid: bool = False
    nodev: bool = False
    disk: str | None = None
    format: DiskImageFormat | None = None
    fstype: str | None = None
    stat_virtualization: StatVirtualization | None = None
    host_permissions: HostPermissions | None = None

    def _to_dict(self) -> dict:
        # Validate every supplied enum before selecting a mount arm. This
        # prevents malformed values in an inactive arm from being silently
        # accepted and surfacing later if the mount kind changes.
        _enum_value(self.kind, MountKind, "MountConfig.kind")
        named_mode = (
            _enum_value(self.named_mode, NamedVolumeMode, "MountConfig.named_mode")
            if self.named_mode is not None
            else None
        )
        named_kind = (
            _enum_value(self.named_kind, VolumeKind, "MountConfig.named_kind")
            if self.named_kind is not None
            else None
        )
        disk_format = (
            _enum_value(self.format, DiskImageFormat, "MountConfig.format")
            if self.format is not None
            else None
        )
        stat_virtualization = (
            _enum_value(
                self.stat_virtualization,
                StatVirtualization,
                "MountConfig.stat_virtualization",
            )
            if self.stat_virtualization is not None
            else None
        )
        host_permissions = (
            _enum_value(
                self.host_permissions,
                HostPermissions,
                "MountConfig.host_permissions",
            )
            if self.host_permissions is not None
            else None
        )
        # Drive emission off `kind` exclusively so a `MountConfig` with
        # contradictory fields (e.g. kind=DISK + bind=...) raises here
        # rather than silently letting the wrong arm of `apply_mount` win.
        d: dict = {
            "readonly": self.readonly,
            "noexec": self.noexec,
            "nosuid": self.nosuid,
            "nodev": self.nodev,
        }
        if self.kind == MountKind.BIND:
            if self.bind is None:
                raise ValueError("MountConfig kind=BIND requires bind=...")
            d["bind"] = self.bind
            if self.quota_mib is not None:
                d["quota_mib"] = self.quota_mib
        elif self.kind == MountKind.NAMED:
            if self.named is None:
                raise ValueError("MountConfig kind=NAMED requires named=...")
            d["named"] = self.named
            if named_mode is not None:
                d["named_mode"] = named_mode
            if named_kind is not None:
                d["named_kind"] = named_kind
            if self.size_mib is not None:
                d["size_mib"] = self.size_mib
            if self.quota_mib is not None:
                d["quota_mib"] = self.quota_mib
        elif self.kind == MountKind.TMPFS:
            d["tmpfs"] = True
            if self.size_mib is not None:
                d["size_mib"] = self.size_mib
        elif self.kind == MountKind.DISK:
            if self.disk is None:
                raise ValueError("MountConfig kind=DISK requires disk=...")
            d["disk"] = self.disk
            if disk_format is not None:
                d["format"] = disk_format
            if self.fstype is not None:
                d["fstype"] = self.fstype
        else:  # pragma: no cover - StrEnum exhaustive above
            raise ValueError(f"unknown MountKind: {self.kind!r}")

        # Per-mount policies — only valid for virtiofs-backed kinds.
        if self.kind in (MountKind.BIND, MountKind.NAMED):
            if stat_virtualization is not None:
                d["stat_virtualization"] = stat_virtualization
            if host_permissions is not None:
                d["host_permissions"] = host_permissions
        elif self.stat_virtualization is not None or self.host_permissions is not None:
            raise ValueError(
                f"stat_virtualization/host_permissions are only valid for "
                f"BIND/NAMED mounts (got kind={self.kind.value})"
            )
        return d


def _enum_value(value: enum.Enum, expected: type[enum.Enum], field_name: str) -> str:
    """Return an enum's wire value while rejecting legacy plain strings."""
    if not isinstance(value, expected):
        raise TypeError(f"{field_name} must be {expected.__name__}")
    return str(value.value)


# --------------------------------------------------------------------------------------------------
# Types: Image
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class RootDiskConfig:
    """Writable rootfs layer (root disk) for an OCI image. Construct via RootDisk."""

    kind: RootDiskKind
    size_mib: int | None = None
    path: str | None = None
    format: DiskImageFormat | None = None
    fstype: str | None = None
    clone: FlatClone | None = None

    def _to_dict(self) -> dict:
        kind = _enum_value(self.kind, RootDiskKind, "RootDiskConfig.kind")
        disk_format = (
            _enum_value(self.format, DiskImageFormat, "RootDiskConfig.format")
            if self.format is not None
            else None
        )
        d: dict = {"kind": kind}
        if self.size_mib is not None:
            d["size_mib"] = self.size_mib
        if self.path is not None:
            d["path"] = self.path
        if disk_format is not None:
            d["format"] = disk_format
        if self.fstype is not None:
            d["fstype"] = self.fstype
        if self.clone is not None:
            d["clone"] = _enum_value(self.clone, FlatClone, "RootDiskConfig.clone")
        return d


class RootDisk:
    """Factory for root disk configurations, used with Image.oci(root_disk=...)."""

    @staticmethod
    def managed(size_mib: int | None = None) -> RootDiskConfig:
        """Sparse ext4 created and owned by microsandbox. Default kind;
        size defaults to 4096 MiB when omitted."""
        return RootDiskConfig(kind=RootDiskKind.MANAGED, size_mib=size_mib)

    @staticmethod
    def tmpfs(size_mib: int | None = None) -> RootDiskConfig:
        """RAM-backed upper: ephemeral, pristine on every boot. The size
        counts against guest memory; defaults to half the sandbox memory."""
        return RootDiskConfig(kind=RootDiskKind.TMPFS, size_mib=size_mib)

    @staticmethod
    def disk(
        path: str,
        *,
        format: DiskImageFormat | None = None,
        fstype: str | None = None,
    ) -> RootDiskConfig:
        """User-supplied disk image attached writable as the upper. Format is
        derived from the file extension unless given (vmdk is not supported)."""
        return RootDiskConfig(kind=RootDiskKind.DISK_IMAGE, path=path, format=format, fstype=fstype)


    @staticmethod
    def flat(
        size_mib: int | None = None,
        *,
        fstype: str | None = None,
        clone: FlatClone = FlatClone.AUTO,
    ) -> RootDiskConfig:
        """Complete microsandbox-owned OCI rootfs mounted directly without OverlayFS."""
        return RootDiskConfig(
            kind=RootDiskKind.FLAT,
            size_mib=size_mib,
            fstype=fstype,
            clone=clone,
        )

@dataclass(frozen=True, slots=True)
class ImageSource:
    """Explicit rootfs image source."""

    _type: ImageSourceKind
    _path: str | None = None
    _reference: str | None = None
    _root_disk: RootDiskConfig | int | None = None
    _upper_size_mib: int | None = None  # deprecated: use _root_disk
    _fstype: str | None = None
    _format: DiskImageFormat | None = None

    def _to_image_str(self) -> str:
        """Convert to the string form the Rust SDK expects."""
        if not isinstance(self._type, ImageSourceKind):
            raise TypeError("ImageSource._type must be ImageSourceKind")
        if self._type == ImageSourceKind.OCI and self._reference is not None:
            return self._reference
        if self._type == ImageSourceKind.BIND and self._path is not None:
            return self._path
        if self._type == ImageSourceKind.DISK and self._path is not None:
            return self._path
        raise ValueError(f"invalid ImageSource: type={self._type}")


class Image:
    """Factory for explicit image source configuration."""

    @staticmethod
    def oci(
        reference: str,
        *,
        root_disk: RootDiskConfig | int | None = None,
        upper_size_mib: int | None = None,
    ) -> ImageSource:
        if root_disk is not None and upper_size_mib is not None:
            raise ValueError("pass either root_disk= or upper_size_mib=, not both")
        if root_disk is not None and type(root_disk) not in (RootDiskConfig, int):
            raise TypeError("root_disk must be RootDiskConfig, int, or None")
        if root_disk is None and upper_size_mib is not None:
            # Deprecated alias: upper_size_mib= is managed-root-disk sugar.
            root_disk = RootDisk.managed(upper_size_mib)
        return ImageSource(
            _type=ImageSourceKind.OCI,
            _reference=reference,
            _root_disk=root_disk,
            _upper_size_mib=upper_size_mib,
        )

    @staticmethod
    def bind(path: str) -> ImageSource:
        return ImageSource(_type=ImageSourceKind.BIND, _path=path)

    @staticmethod
    def disk(
        path: str,
        *,
        fstype: str | None = None,
    ) -> ImageSource:
        """Create a disk image rootfs. Format auto-detected from extension."""
        return ImageSource(_type=ImageSourceKind.DISK, _path=path, _fstype=fstype)


# --------------------------------------------------------------------------------------------------
# Types: Patch
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class PatchConfig:
    """A rootfs patch applied before VM startup."""

    kind: PatchKind
    path: str | None = None
    content: str | bytes | None = None
    src: str | None = None
    dst: str | None = None
    target: str | None = None
    link: str | None = None
    mode: int | None = None
    replace: bool = False

    def _to_dict(self) -> dict:
        kind = _enum_value(self.kind, PatchKind, "PatchConfig.kind")
        d: dict = {"kind": kind}

        if self.kind in (
            PatchKind.TEXT,
            PatchKind.FILE,
            PatchKind.APPEND,
            PatchKind.MKDIR,
            PatchKind.REMOVE,
        ):
            if not isinstance(self.path, str):
                raise TypeError(f"PatchConfig kind={kind!r} requires path to be str")
            d["path"] = self.path

        if self.kind in (PatchKind.TEXT, PatchKind.APPEND):
            if not isinstance(self.content, str):
                raise TypeError(f"PatchConfig kind={kind!r} requires content to be str")
            d["content"] = self.content
        elif self.kind is PatchKind.FILE:
            if not isinstance(self.content, bytes):
                raise TypeError("PatchConfig kind='file' requires content to be bytes")
            d["content"] = self.content
        elif self.kind in (PatchKind.COPY_FILE, PatchKind.COPY_DIR):
            if not isinstance(self.src, str) or not isinstance(self.dst, str):
                raise TypeError(f"PatchConfig kind={kind!r} requires src and dst to be str")
            d.update(src=self.src, dst=self.dst)
        elif self.kind is PatchKind.SYMLINK:
            if not isinstance(self.target, str) or not isinstance(self.link, str):
                raise TypeError("PatchConfig kind='symlink' requires target and link to be str")
            d.update(target=self.target, link=self.link)

        if self.mode is not None and self.kind in (
            PatchKind.TEXT,
            PatchKind.FILE,
            PatchKind.COPY_FILE,
            PatchKind.MKDIR,
        ):
            d["mode"] = self.mode
        if self.replace and self.kind in (
            PatchKind.TEXT,
            PatchKind.FILE,
            PatchKind.COPY_FILE,
            PatchKind.COPY_DIR,
            PatchKind.SYMLINK,
        ):
            d["replace"] = True
        return d


class Patch:
    """Factory for rootfs patch configurations."""

    @staticmethod
    def text(
        path: str,
        content: str,
        *,
        mode: int | None = None,
        replace: bool = False,
    ) -> PatchConfig:
        return PatchConfig(
            kind=PatchKind.TEXT,
            path=path,
            content=content,
            mode=mode,
            replace=replace,
        )

    @staticmethod
    def file(
        path: str,
        content: bytes,
        *,
        mode: int | None = None,
        replace: bool = False,
    ) -> PatchConfig:
        """Write raw bytes to a file in the guest root filesystem."""
        return PatchConfig(
            kind=PatchKind.FILE,
            path=path,
            content=content,
            mode=mode,
            replace=replace,
        )

    @staticmethod
    def mkdir(path: str, *, mode: int | None = None) -> PatchConfig:
        return PatchConfig(kind=PatchKind.MKDIR, path=path, mode=mode)

    @staticmethod
    def append(path: str, content: str) -> PatchConfig:
        return PatchConfig(kind=PatchKind.APPEND, path=path, content=content)

    @staticmethod
    def copy_file(
        src: str,
        dst: str,
        *,
        mode: int | None = None,
        replace: bool = False,
    ) -> PatchConfig:
        return PatchConfig(
            kind=PatchKind.COPY_FILE,
            src=src,
            dst=dst,
            mode=mode,
            replace=replace,
        )

    @staticmethod
    def copy_dir(src: str, dst: str, *, replace: bool = False) -> PatchConfig:
        return PatchConfig(kind=PatchKind.COPY_DIR, src=src, dst=dst, replace=replace)

    @staticmethod
    def symlink(target: str, link: str, *, replace: bool = False) -> PatchConfig:
        return PatchConfig(kind=PatchKind.SYMLINK, target=target, link=link, replace=replace)

    @staticmethod
    def remove(path: str) -> PatchConfig:
        return PatchConfig(kind=PatchKind.REMOVE, path=path)


# --------------------------------------------------------------------------------------------------
# Types: Secret
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class SecretInjection:
    """Where in the HTTP request the secret value can be substituted."""

    headers: bool = True
    basic_auth: bool = True
    query_params: bool = False
    body: bool = False

    def _to_dict(self) -> dict:
        d: dict = {}
        if not self.headers:
            d["headers"] = False
        if not self.basic_auth:
            d["basic_auth"] = False
        if self.query_params:
            d["query_params"] = True
        if self.body:
            d["body"] = True
        return d


@dataclass(frozen=True, slots=True)
class SecretEntry:
    """A secret entry for the secrets array."""

    env_var: str
    value: str
    allow_hosts: tuple[str, ...] = ()
    allow_host_patterns: tuple[str, ...] = ()
    placeholder: str | None = None
    require_tls: bool = True
    on_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG
    injection: SecretInjection = field(default_factory=SecretInjection)

    def _to_dict(self) -> dict:
        d: dict = {"env_var": self.env_var, "value": self.value}
        if self.allow_hosts:
            d["allow_hosts"] = list(self.allow_hosts)
        if self.allow_host_patterns:
            d["allow_host_patterns"] = list(self.allow_host_patterns)
        if self.placeholder is not None:
            d["placeholder"] = self.placeholder
        if not self.require_tls:
            d["require_tls"] = False
        violation = violation_policy_to_dict(self.on_violation)
        if violation != str(ViolationAction.BLOCK_AND_LOG):
            d["on_violation"] = violation
        if not isinstance(self.injection, SecretInjection):
            raise TypeError("SecretEntry.injection must be SecretInjection")
        injection = self.injection._to_dict()
        if injection:
            d["injection"] = injection
        return d


class Secret:
    """Factory for secret entries."""

    @staticmethod
    def env(
        env_var: str,
        *,
        value: str,
        allow_hosts: Sequence[str] = (),
        allow_host_patterns: Sequence[str] = (),
        placeholder: str | None = None,
        require_tls: bool = True,
        on_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG,
        injection: SecretInjection | None = None,
    ) -> SecretEntry:
        return SecretEntry(
            env_var=env_var,
            value=value,
            allow_hosts=tuple(allow_hosts),
            allow_host_patterns=tuple(allow_host_patterns),
            placeholder=placeholder,
            require_tls=require_tls,
            on_violation=on_violation,
            injection=injection if injection is not None else SecretInjection(),
        )


# --------------------------------------------------------------------------------------------------
# Types: Network
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class NetworkDestination:
    """Typed network policy destination."""

    kind: NetworkDestinationKind
    value: str | None = None

    def _to_dict(self) -> dict:
        d = {
            "destination_kind": _enum_value(
                self.kind, NetworkDestinationKind, "NetworkDestination.kind"
            )
        }
        if self.value is not None:
            d["destination"] = self.value
        return d


class Destination:
    """Factory for typed network policy destinations."""

    @staticmethod
    def any() -> NetworkDestination:
        return NetworkDestination(NetworkDestinationKind.ANY)

    @staticmethod
    def ip(ip: str) -> NetworkDestination:
        return NetworkDestination(NetworkDestinationKind.IP, ip)

    @staticmethod
    def cidr(cidr: str) -> NetworkDestination:
        return NetworkDestination(NetworkDestinationKind.CIDR, cidr)

    @staticmethod
    def domain(domain: str) -> NetworkDestination:
        return NetworkDestination(NetworkDestinationKind.DOMAIN, domain)

    @staticmethod
    def domain_suffix(suffix: str) -> NetworkDestination:
        return NetworkDestination(NetworkDestinationKind.DOMAIN_SUFFIX, suffix)

    @staticmethod
    def group(group: DestGroup) -> NetworkDestination:
        return NetworkDestination(
            NetworkDestinationKind.GROUP,
            _enum_value(group, DestGroup, "Destination.group.group"),
        )


NetworkDestinationLike: TypeAlias = str | NetworkDestination | None


@dataclass(frozen=True, slots=True)
class Rule:
    """A network policy rule."""

    action: Action
    direction: Direction = Direction.EGRESS
    destination: NetworkDestinationLike = None
    protocol: Protocol | None = None
    port: int | str | None = None

    @classmethod
    def allow(
        cls,
        *,
        direction: Direction = Direction.EGRESS,
        protocol: Protocol | None = None,
        port: int | str | None = None,
        destination: NetworkDestinationLike = None,
    ) -> Rule:
        return cls(Action.ALLOW, direction, destination, protocol, port)

    @classmethod
    def deny(
        cls,
        *,
        direction: Direction = Direction.EGRESS,
        protocol: Protocol | None = None,
        port: int | str | None = None,
        destination: NetworkDestinationLike = None,
    ) -> Rule:
        return cls(Action.DENY, direction, destination, protocol, port)

    @classmethod
    def allow_dns(cls) -> tuple[Rule, Rule]:
        """Allow plain DNS (UDP/53 and TCP/53) to the sandbox gateway.

        Returns the pair `(udp_rule, tcp_rule)` since this SDK's
        `Rule` shape is single-protocol. Splat into `NetworkPolicy.rules`
        to open DNS under a deny-by-default policy:

            NetworkPolicy(rules=(*Rule.allow_dns(), ...))

        DoT (TCP/853) is intentionally not included; add an explicit
        `destination=Destination.group(DestGroup.HOST)`,
        `protocol=Protocol.TCP`, `port=853` rule if needed (and pair with
        TLS interception).
        """
        return (
            cls(
                Action.ALLOW,
                Direction.EGRESS,
                Destination.group(DestGroup.HOST),
                Protocol.UDP,
                53,
            ),
            cls(
                Action.ALLOW,
                Direction.EGRESS,
                Destination.group(DestGroup.HOST),
                Protocol.TCP,
                53,
            ),
        )

    @classmethod
    def deny_dns(cls) -> tuple[Rule, Rule]:
        """Deny gateway UDP/53 and TCP/53, for overriding profile DNS."""
        udp, tcp = cls.allow_dns()
        return (
            cls(Action.DENY, udp.direction, udp.destination, udp.protocol, udp.port),
            cls(Action.DENY, tcp.direction, tcp.destination, tcp.protocol, tcp.port),
        )


@dataclass(frozen=True, slots=True)
class NetworkPolicy:
    """Custom network policy with rules.

    Mirrors Rust's `NetworkPolicy { default_egress, default_ingress, rules }`.
    The defaults are asymmetric to preserve today's behavior:
    egress falls through to deny (the default public-profile reachability when
    paired with an allow-public rule); ingress falls through
    to allow (today's unfiltered published-port behavior).
    """

    default_egress: Action = Action.DENY
    default_ingress: Action = Action.ALLOW
    rules: tuple[Rule, ...] = ()

    @classmethod
    def none(cls) -> NetworkPolicy:
        """Deny traffic in both directions."""
        return cls(default_egress=Action.DENY, default_ingress=Action.DENY)

    @classmethod
    def allow_all(cls) -> NetworkPolicy:
        """Allow traffic in both directions."""
        return cls(default_egress=Action.ALLOW, default_ingress=Action.ALLOW)

    @classmethod
    def from_profiles(cls, profiles: Iterable[NetworkProfile]) -> NetworkPolicy:
        """Build a canonical deny-by-default policy from profiles."""
        requested: set[NetworkProfile] = set()
        for profile in profiles:
            if not isinstance(profile, NetworkProfile):
                raise TypeError("profiles must contain NetworkProfile members")
            requested.add(profile)
        rules: list[Rule] = []
        if requested:
            rules.extend(Rule.allow_dns())
        for profile in (
            NetworkProfile.PUBLIC,
            NetworkProfile.PRIVATE,
            NetworkProfile.HOST,
        ):
            if profile in requested:
                rules.append(Rule.allow(destination=Destination.group(DestGroup(profile.value))))
        return cls(rules=tuple(rules))

    def _to_dict(self) -> dict:
        def destination_fields(destination: NetworkDestinationLike) -> dict:
            if destination is None:
                return {}
            if isinstance(destination, NetworkDestination):
                return destination._to_dict()
            # StrEnum members are strings too, so require a plain string here
            # to keep unrelated enum classes from crossing this open grammar.
            if type(destination) is not str:
                raise TypeError("Rule.destination must be str, NetworkDestination, or None")
            return {"destination": destination}

        d: dict = {
            "default_egress": _enum_value(
                self.default_egress, Action, "NetworkPolicy.default_egress"
            ),
            "default_ingress": _enum_value(
                self.default_ingress, Action, "NetworkPolicy.default_ingress"
            ),
        }
        if self.rules:
            for rule in self.rules:
                if not isinstance(rule, Rule):
                    raise TypeError("NetworkPolicy.rules must contain Rule values")
                if rule.port is not None and type(rule.port) not in (int, str):
                    raise TypeError("Rule.port must be int, str, or None")
            d["rules"] = [
                {
                    "action": _enum_value(r.action, Action, "Rule.action"),
                    "direction": _enum_value(r.direction, Direction, "Rule.direction"),
                    **destination_fields(r.destination),
                    **(
                        {"protocol": _enum_value(r.protocol, Protocol, "Rule.protocol")}
                        if r.protocol is not None
                        else {}
                    ),
                    **({"port": str(r.port)} if r.port is not None else {}),
                }
                for r in self.rules
            ]
        return d


@dataclass(frozen=True, slots=True)
class ScopedUpstreamCACert:
    """Host-scoped upstream CA certificate path."""

    pattern: str
    path: str

    def _to_dict(self) -> dict:
        return {"pattern": self.pattern, "path": self.path}


@dataclass(frozen=True, slots=True)
class ScopedVerifyUpstream:
    """Host-scoped upstream certificate verification override."""

    pattern: str
    verify: bool

    def _to_dict(self) -> dict:
        return {"pattern": self.pattern, "verify": self.verify}


@dataclass(frozen=True, slots=True)
class TlsConfig:
    """TLS interception configuration."""

    bypass: tuple[str, ...] = ()
    verify_upstream: bool = True
    intercepted_ports: tuple[int, ...] = (443,)
    block_quic: bool = False
    upstream_ca_certs: tuple[str, ...] = ()
    scoped_upstream_ca_certs: tuple[ScopedUpstreamCACert, ...] = ()
    scoped_verify_upstream: tuple[ScopedVerifyUpstream, ...] = ()
    ca_cert: str | None = None
    ca_key: str | None = None
    ca_cn: str | None = None

    def _to_dict(self) -> dict:
        d: dict = {}
        if self.bypass:
            d["bypass"] = list(self.bypass)
        if not self.verify_upstream:
            d["verify_upstream"] = False
        if self.intercepted_ports != (443,):
            d["intercepted_ports"] = list(self.intercepted_ports)
        if self.block_quic:
            d["block_quic"] = True
        if self.upstream_ca_certs:
            d["upstream_ca_certs"] = list(self.upstream_ca_certs)
        if self.scoped_upstream_ca_certs:
            d["scoped_upstream_ca_certs"] = [
                scoped._to_dict() for scoped in self.scoped_upstream_ca_certs
            ]
        if self.scoped_verify_upstream:
            d["scoped_verify_upstream"] = [
                scoped._to_dict() for scoped in self.scoped_verify_upstream
            ]
        if self.ca_cert is not None:
            d["ca_cert"] = self.ca_cert
        if self.ca_key is not None:
            d["ca_key"] = self.ca_key
        if self.ca_cn is not None:
            d["ca_cn"] = self.ca_cn
        return d


@dataclass(frozen=True, slots=True)
class DnsConfig:
    """DNS interception configuration."""

    rebind_protection: bool = True
    """Block DNS responses resolving to private IPs. Default: True."""
    nameservers: tuple[str, ...] = ()
    """Nameservers to forward queries to. Accepts IP, IP:PORT, HOST, or
    HOST:PORT. When set, overrides the host's /etc/resolv.conf."""
    query_timeout_ms: int | None = None
    """Per-DNS-query timeout in milliseconds. Default: 5000."""

    def _to_dict(self) -> dict:
        d: dict = {}
        if not self.rebind_protection:
            d["rebind_protection"] = False
        if self.nameservers:
            d["nameservers"] = list(self.nameservers)
        if self.query_timeout_ms is not None:
            d["query_timeout_ms"] = self.query_timeout_ms
        return d


@dataclass(frozen=True, slots=True)
class PortBinding:
    """Published host-to-guest port with an optional host bind address."""

    host_port: int
    guest_port: int
    bind: str = "127.0.0.1"
    protocol: PortProtocol = PortProtocol.TCP

    @classmethod
    def tcp(cls, host_port: int, guest_port: int, *, bind: str = "127.0.0.1") -> PortBinding:
        return cls(host_port=host_port, guest_port=guest_port, bind=bind, protocol=PortProtocol.TCP)

    @classmethod
    def udp(cls, host_port: int, guest_port: int, *, bind: str = "127.0.0.1") -> PortBinding:
        return cls(host_port=host_port, guest_port=guest_port, bind=bind, protocol=PortProtocol.UDP)

    def _to_dict(self) -> dict:
        return {
            "host_port": self.host_port,
            "guest_port": self.guest_port,
            "bind": self.bind,
            "protocol": _enum_value(self.protocol, PortProtocol, "PortBinding.protocol"),
        }


@dataclass(frozen=True, slots=True)
class TokenBucket:
    """One token bucket of a rate limiter.

    The bucket starts full and refills continuously at ``size`` tokens per
    ``refill_time_ms``. ``one_time_burst`` grants extra startup tokens that
    are spent before the regular budget and never refill.
    """
    size: int
    """Bucket capacity in tokens: bytes for bandwidth, frames for ops."""
    refill_time_ms: int
    """Time to refill ``size`` tokens, in milliseconds."""
    one_time_burst: int = 0
    """Extra tokens granted once at startup. Default: 0."""

    def _to_dict(self) -> dict:
        d: dict = {"size": self.size, "refill_time_ms": self.refill_time_ms}
        if self.one_time_burst:
            d["one_time_burst"] = self.one_time_burst
        return d


@dataclass(frozen=True, slots=True)
class RateLimiter:
    """Rate limiter for one traffic direction.

    Caps bandwidth (bytes) and packet rate (frames) independently; a
    missing bucket leaves that dimension unlimited.
    """
    bandwidth: TokenBucket | None = None
    """Bandwidth bucket. One token is one byte of frame data."""
    ops: TokenBucket | None = None
    """Operations bucket. One token is one network frame."""

    def _to_dict(self) -> dict:
        d: dict = {}
        if self.bandwidth is not None:
            d["bandwidth"] = self.bandwidth._to_dict()
        if self.ops is not None:
            d["ops"] = self.ops._to_dict()
        return d


@dataclass(frozen=True, slots=True)
class NetworkRateLimiter:
    """Egress and ingress rate limits for a local sandbox network."""

    egress: RateLimiter | None = None
    """Guest-to-runtime rate limiter. ``None`` means unlimited."""
    ingress: RateLimiter | None = None
    """Runtime-to-guest rate limiter. ``None`` means unlimited."""

    def _to_dict(self) -> dict:
        d: dict = {}
        if self.egress is not None:
            if not isinstance(self.egress, RateLimiter):
                raise TypeError("NetworkRateLimiter.egress must be RateLimiter or None")
            d["egress"] = self.egress._to_dict()
        if self.ingress is not None:
            if not isinstance(self.ingress, RateLimiter):
                raise TypeError("NetworkRateLimiter.ingress must be RateLimiter or None")
            d["ingress"] = self.ingress._to_dict()
        return d


@dataclass(frozen=True, slots=True)
class VsockRoute:
    """Host Unix socket or Windows named pipe exposed on host CID 2."""

    host_socket: str | os.PathLike[str]
    port: int
    socket_type: VsockSocketType = VsockSocketType.STREAM

    @classmethod
    def stream(cls, host_socket: str | os.PathLike[str], port: int) -> VsockRoute:
        return cls(host_socket=host_socket, port=port)

    @classmethod
    def dgram(cls, host_socket: str | os.PathLike[str], port: int) -> VsockRoute:
        return cls(host_socket=host_socket, port=port, socket_type=VsockSocketType.DGRAM)

    def _to_dict(self) -> dict:
        return {
            "host_socket": os.fspath(self.host_socket),
            "port": self.port,
            "socket_type": _enum_value(self.socket_type, VsockSocketType, "VsockRoute.socket_type"),
        }


@dataclass(frozen=True, slots=True)
class Network:
    """Network configuration for a sandbox."""

    policy: NetworkPolicy | None = None
    ports: Mapping[int, int] | Sequence[PortBinding] = field(default_factory=dict)
    deny_domains: tuple[str, ...] = ()
    """Deny egress to these exact domains. Each entry adds a
    `deny Domain("...")` policy rule that fires at DNS resolution
    (NXDOMAIN), TLS first-flight (SNI), and TCP egress (cache fallback).
    Prepended onto the policy so it takes precedence over later allow
    rules."""
    deny_domain_suffixes: tuple[str, ...] = ()
    """Deny egress to all subdomains of these suffixes. Same enforcement
    layers as `deny_domains`."""
    dns: DnsConfig | None = None
    tls: TlsConfig | None = None
    ipv4_pool: str | None = None
    """IPv4 pool used to derive per-sandbox /30 guest subnets. Defaults
    to ``172.16.0.0/12``."""
    ipv6_pool: str | None = None
    """IPv6 pool used to derive per-sandbox /64 guest prefixes. Defaults
    to ``fd42:6d73:62::/48``."""
    max_connections: int | None = None
    rate_limiter: NetworkRateLimiter | None = None
    """Local egress and ingress rate limits. ``None`` means unlimited."""
    on_secret_violation: ViolationAction | ViolationPolicy = ViolationAction.BLOCK_AND_LOG

    @classmethod
    def none(cls) -> Network:
        return cls(policy=NetworkPolicy.none())

    @classmethod
    def from_profiles(cls, *profiles: NetworkProfile) -> Network:
        return cls(policy=NetworkPolicy.from_profiles(profiles))

    @classmethod
    def allow_all(cls) -> Network:
        return cls(policy=NetworkPolicy.allow_all())

    def _to_dict(self) -> dict:
        d: dict = {}
        if isinstance(self.policy, NetworkPolicy):
            d["custom_policy"] = self.policy._to_dict()
        elif self.policy is not None:
            raise TypeError(
                "Network.policy must be a NetworkPolicy; use "
                "Network.from_profiles(...), Network.none(), or Network.allow_all()"
            )
        if self.ports:
            if isinstance(self.ports, Mapping):
                d["ports"] = dict(self.ports)
            else:
                port_dicts = []
                for port in self.ports:
                    if not isinstance(port, PortBinding):
                        raise TypeError("Network.ports must contain PortBinding values")
                    port_dicts.append(port._to_dict())
                d["ports"] = port_dicts
        if self.deny_domains:
            d["deny_domains"] = list(self.deny_domains)
        if self.deny_domain_suffixes:
            d["deny_domain_suffixes"] = list(self.deny_domain_suffixes)
        if self.dns is not None:
            if not isinstance(self.dns, DnsConfig):
                raise TypeError("Network.dns must be DnsConfig or None")
            dns_dict = self.dns._to_dict()
            if dns_dict:
                d["dns"] = dns_dict
        if self.tls is not None:
            if not isinstance(self.tls, TlsConfig):
                raise TypeError("Network.tls must be TlsConfig or None")
            d["tls"] = self.tls._to_dict()
        if self.ipv4_pool is not None:
            d["ipv4_pool"] = self.ipv4_pool
        if self.ipv6_pool is not None:
            d["ipv6_pool"] = self.ipv6_pool
        if self.max_connections is not None:
            d["max_connections"] = self.max_connections
        if self.rate_limiter is not None:
            if not isinstance(self.rate_limiter, NetworkRateLimiter):
                raise TypeError("Network.rate_limiter must be NetworkRateLimiter or None")
            d["rate_limiter"] = self.rate_limiter._to_dict()
        violation = violation_policy_to_dict(self.on_secret_violation)
        if violation != str(ViolationAction.BLOCK_AND_LOG):
            d["on_secret_violation"] = violation
        return d


def violation_policy_to_dict(
    policy: ViolationAction | ViolationPolicy,
) -> ViolationAction | dict:
    if isinstance(policy, ViolationPolicy):
        return policy._to_dict()
    _enum_value(policy, ViolationAction, "on_violation")
    return policy


# --------------------------------------------------------------------------------------------------
# Types: Registry Auth
# --------------------------------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class RegistryAuth:
    """Registry credentials for pulling private images."""

    username: str
    password: str

    @classmethod
    def basic(cls, username: str, password: str) -> RegistryAuth:
        return cls(username=username, password=password)

    def _to_dict(self) -> dict:
        return {"username": self.username, "password": self.password}
