"""Typer-based CLI for Omni SDK."""

from __future__ import annotations

import asyncio
import base64
import json
import os
import shlex
import shutil
import subprocess
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

import httpx
import typer
import yaml
from rich.console import Console
from rich.table import Table

from omni import configure, get_sync_client, set_default_cluster, set_default_instance
from omni.api.crd import resolver as crd_resolver_factory
from omni.cli.history import HISTORY_MAX_ENTRIES, redact_argv, redacted_command
from omni.cli.talos_manager import DEFAULT_REFRESH_TTL_SECONDS, KEY_DEFAULT_CLUSTER, TalosConfigManager
from omni.config import load_config
from omni.http.transport import OmniHTTPError

app = typer.Typer(no_args_is_help=True, help="Python Omni CLI (omnipy)")
config_app = typer.Typer(help="Config and preference commands")
config_talos_app = typer.Typer(help="Talos wrapper preferences")
config_history_app = typer.Typer(help="CLI history preferences")
crd_app = typer.Typer(help="Resource/CRD catalog discovery helpers")
serviceaccount_app = typer.Typer(help="Service account management")
cluster_app = typer.Typer(help="Cluster operations")
cluster_machine_app = typer.Typer(help="Cluster machine operations")
cluster_kubernetes_app = typer.Typer(help="Cluster Kubernetes operations")
jointoken_app = typer.Typer(help="Join token operations")
infraprovider_app = typer.Typer(help="Infra provider operations")
user_app = typer.Typer(help="User operations")

app.add_typer(config_app, name="config")
config_app.add_typer(config_talos_app, name="talos")
config_app.add_typer(config_history_app, name="history")
app.add_typer(crd_app, name="crd")
app.add_typer(serviceaccount_app, name="serviceaccount")
app.add_typer(cluster_app, name="cluster")
app.add_typer(cluster_app, name="c")
app.add_typer(jointoken_app, name="jointoken")
app.add_typer(jointoken_app, name="jt")
app.add_typer(infraprovider_app, name="infraprovider")
app.add_typer(infraprovider_app, name="ip")
app.add_typer(user_app, name="user")
app.add_typer(user_app, name="u")
cluster_app.add_typer(cluster_machine_app, name="machine")
cluster_app.add_typer(cluster_kubernetes_app, name="kubernetes")
cluster_app.add_typer(cluster_kubernetes_app, name="k")

console = Console()


@dataclass(slots=True)
class CLIContext:
    config_path: str | None = None
    instance: str | None = None
    cluster: str | None = None


def _ctx_client(ctx: typer.Context):
    cfg = ctx.obj
    if not isinstance(cfg, CLIContext):
        raise typer.Exit(code=2)
    return get_sync_client(instance=cfg.instance, cluster=cfg.cluster)


def _run_coro_sync(coro: Any) -> Any:
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(coro)
    raise RuntimeError("command cannot run an async operation inside an active event loop")


def _decode_embedded_json(payload: dict[str, Any]) -> dict[str, Any]:
    body = payload.get("body")
    if isinstance(body, str):
        try:
            decoded = json.loads(body)
            if isinstance(decoded, dict):
                return decoded
        except json.JSONDecodeError:
            return payload
    return payload


def _decode_list_items(payload: dict[str, Any]) -> dict[str, Any]:
    items = payload.get("items")
    if not isinstance(items, list):
        return payload

    decoded: list[Any] = []
    for item in items:
        if isinstance(item, str):
            try:
                decoded.append(json.loads(item))
                continue
            except json.JSONDecodeError:
                pass
        decoded.append(item)
    copied = dict(payload)
    copied["items"] = decoded
    return copied


def _pick_value(payload: dict[str, Any], *keys: str) -> Any:
    for key in keys:
        if key in payload:
            return payload[key]
    return None


def _as_spec(payload: dict[str, Any]) -> dict[str, Any]:
    spec = payload.get("spec")
    if isinstance(spec, dict):
        return dict(spec)
    return {}


def _as_metadata(payload: dict[str, Any]) -> dict[str, Any]:
    md = payload.get("metadata")
    if isinstance(md, dict):
        return dict(md)
    return {}


def _rfc3339_after_seconds(ttl_seconds: int) -> str:
    return (datetime.now(tz=UTC) + timedelta(seconds=ttl_seconds)).isoformat().replace("+00:00", "Z")


def _metadata_for_update(metadata: dict[str, Any]) -> dict[str, Any]:
    result: dict[str, Any] = {
        "namespace": str(metadata.get("namespace", "default")),
        "type": str(metadata.get("type", "")),
        "id": str(metadata.get("id", "")),
    }
    labels = metadata.get("labels")
    annotations = metadata.get("annotations")
    if isinstance(labels, dict):
        result["labels"] = labels
    if isinstance(annotations, dict):
        result["annotations"] = annotations
    return result


def _update_resource_spec(
    client: Any,
    *,
    namespace: str,
    resource_type: str,
    resource_id: str,
    spec: dict[str, Any],
) -> dict[str, Any]:
    fetched = client.resources.get({"namespace": namespace, "type": resource_type, "id": resource_id})
    resource = _decode_embedded_json(fetched)
    metadata = _as_metadata(resource)
    req = {
        "currentVersion": str(metadata.get("version", "")),
        "resource": {
            "metadata": _metadata_for_update(metadata),
            "spec": json.dumps(spec),
        },
    }
    return client.resources.update(req)


def _join_url(base_url: str, token_id: str) -> str:
    parts = urlsplit(base_url)
    query_items = dict(parse_qsl(parts.query, keep_blank_values=True))
    query_items["jointoken"] = token_id
    return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query_items), parts.fragment))


def _decode_bytes_field(payload: dict[str, Any], *keys: str) -> bytes:
    value = _pick_value(payload, *keys)
    if isinstance(value, bytes):
        return value
    if not isinstance(value, str):
        return b""

    try:
        return base64.b64decode(value, validate=True)
    except Exception:
        return value.encode("utf-8")


def _resource_items(payload: dict[str, Any]) -> list[dict[str, Any]]:
    decoded = _decode_list_items(payload)
    items = decoded.get("items")
    if not isinstance(items, list):
        return []
    return [item for item in items if isinstance(item, dict)]


def _require_cluster(ctx: typer.Context, cluster: str | None) -> str:
    if cluster:
        return cluster

    cfg = getattr(ctx, "obj", None)
    if isinstance(cfg, CLIContext) and cfg.cluster:
        return cfg.cluster

    raise typer.BadParameter("cluster is required (set --cluster or configure a default cluster)")


def _arg_has_flag(args: list[str], *flags: str) -> bool:
    for arg in args:
        for flag in flags:
            if arg == flag or arg.startswith(f"{flag}="):
                return True
    return False


def _arg_extract_value(args: list[str], *flags: str) -> str | None:
    for i, arg in enumerate(args):
        for flag in flags:
            if arg == flag and i + 1 < len(args):
                return args[i + 1]
            if arg.startswith(f"{flag}="):
                return arg.split("=", 1)[1]
    return None


def _resolve_talos_nodes_passthrough(
    manager: TalosConfigManager,
    passthrough: list[str],
    *,
    cluster: str | None,
    saved_nodes: str | None,
) -> tuple[list[str], str | None]:
    """Normalize talos `--nodes` / `-n` arguments and map aliases to machine IDs.

    Returns:
    - updated passthrough argv
    - explicit nodes value to persist (None when defaulted from saved context)
    """
    args = list(passthrough)

    for idx, arg in enumerate(args):
        if arg in {"--nodes", "-n"}:
            has_value = idx + 1 < len(args) and not args[idx + 1].startswith("-")
            if not has_value:
                if not saved_nodes:
                    raise typer.BadParameter(
                        "--nodes was provided without a value and no previous Talos node is saved for this context"
                    )
                args.insert(idx + 1, saved_nodes)
                return args, None

            resolved = _run_coro_sync(manager.resolve_nodes(args[idx + 1], cluster=cluster))
            args[idx + 1] = resolved
            return args, resolved

        if arg.startswith("--nodes="):
            raw = arg.split("=", 1)[1]
            if not raw:
                if not saved_nodes:
                    raise typer.BadParameter(
                        "--nodes was provided without a value and no previous Talos node is saved for this context"
                    )
                args[idx] = f"--nodes={saved_nodes}"
                return args, None

            resolved = _run_coro_sync(manager.resolve_nodes(raw, cluster=cluster))
            args[idx] = f"--nodes={resolved}"
            return args, resolved

        if arg.startswith("-n="):
            raw = arg.split("=", 1)[1]
            if not raw:
                if not saved_nodes:
                    raise typer.BadParameter(
                        "-n was provided without a value and no previous Talos node is saved for this context"
                    )
                args[idx] = f"-n={saved_nodes}"
                return args, None

            resolved = _run_coro_sync(manager.resolve_nodes(raw, cluster=cluster))
            args[idx] = f"-n={resolved}"
            return args, resolved

    return args, None


def _talos_manager(ctx: typer.Context) -> TalosConfigManager:
    client = _ctx_client(ctx)
    return TalosConfigManager(client._async)


def _crd_payload(info: Any) -> dict[str, Any]:
    return {
        "type": info.resource_type,
        "const_name": info.const_name,
        "description": info.description,
        "default_namespace": info.default_namespace,
        "print_columns": info.print_columns,
        "aliases": info.aliases,
        "source_file": info.source_file,
    }


def _in_completion_context() -> bool:
    return "_OMNIPY_COMPLETE" in os.environ or "OMNIPY_COMPLETE" in os.environ


def _record_history(
    ctx: typer.Context,
    *,
    instance: str | None,
    cluster: str | None,
    args_override: list[str] | None = None,
) -> None:
    if _in_completion_context():
        return

    if args_override is not None:
        args = ["omnipy", *args_override]
    else:
        trailing = [str(arg) for arg in ctx.args]
        if not trailing and ctx.invoked_subcommand:
            trailing = [str(ctx.invoked_subcommand)]
        args = ["omnipy", *trailing]
    if len(args) <= 1:
        return

    try:
        client = get_sync_client(instance=instance, cluster=cluster)
        redacted_argv = redact_argv(args)
        command = redacted_command(args)
        client._async.state.history_add(
            command=command,
            argv=redacted_argv,
            instance=client._async.instance_name,
            cluster=cluster or client._async.cluster,
            max_entries=HISTORY_MAX_ENTRIES,
        )
    except Exception:
        # History should never block command execution.
        return


def _history_resource_types(ctx: typer.Context, incomplete: str) -> list[str]:
    try:
        cfg = ctx.obj if isinstance(ctx.obj, CLIContext) else CLIContext()
        client = get_sync_client(instance=cfg.instance, cluster=cfg.cluster)
        commands = client._async.state.history_suggest("omnipy get", limit=200)
    except Exception:
        return []

    seen: set[str] = set()
    matches: list[str] = []
    needle = incomplete.lower()
    for command in commands:
        try:
            tokens = shlex.split(command)
        except ValueError:
            continue
        if len(tokens) < 3:
            continue
        if tokens[1] != "get":
            continue
        resource_type = tokens[2]
        if needle and not resource_type.lower().startswith(needle):
            continue
        if resource_type in seen:
            continue
        seen.add(resource_type)
        matches.append(resource_type)
    return matches


def _complete_resource_types(ctx: typer.Context, incomplete: str) -> list[str]:
    resolver = crd_resolver_factory()
    entries = resolver.list()
    seen: set[str] = set()
    suggestions: list[str] = []
    needle = incomplete.lower()
    for entry in entries:
        resource_type = entry.resource_type
        if needle and not resource_type.lower().startswith(needle):
            continue
        if resource_type in seen:
            continue
        seen.add(resource_type)
        suggestions.append(resource_type)
    for resource_type in _history_resource_types(ctx, incomplete):
        if resource_type in seen:
            continue
        seen.add(resource_type)
        suggestions.append(resource_type)
    return suggestions[:60]


def _complete_clusters(ctx: typer.Context, incomplete: str) -> list[str]:
    candidates: set[str] = set()
    cfg = ctx.obj if isinstance(ctx.obj, CLIContext) else CLIContext()

    try:
        client = get_sync_client(instance=cfg.instance, cluster=cfg.cluster)
        state = client._async.state
        default_cluster = state.get_preference("default_cluster")
        if default_cluster:
            candidates.add(default_cluster)
        talos_default = state.get_preference(KEY_DEFAULT_CLUSTER)
        if talos_default:
            candidates.add(talos_default)
        for value in state.get_json_preference("talos.last_cluster_by_instance").values():
            candidates.add(value)
        for value in state.get_json_preference("instance_clusters").values():
            candidates.add(value)
    except Exception:
        pass

    try:
        loaded = load_config(config_path=cfg.config_path)
        if loaded.data.preferences.default_cluster:
            candidates.add(loaded.data.preferences.default_cluster)
        for value in loaded.data.preferences.instance_clusters.values():
            candidates.add(value)
    except Exception:
        pass

    needle = incomplete.lower()
    return sorted(value for value in candidates if value and value.lower().startswith(needle))[:60]


def _matches_installation_media(resource: dict[str, Any], name: str) -> bool:
    metadata = _as_metadata(resource)
    spec = _as_spec(resource)

    needle = name.strip().lower()
    if not needle:
        return False

    media_id = str(metadata.get("id", "")).lower()
    media_name = str(_pick_value(spec, "name") or "").lower()
    profile = str(_pick_value(spec, "profile") or "").lower()
    overlay = str(_pick_value(spec, "overlay") or "").lower()

    if needle == "iso":
        return needle in media_name or needle in media_id or needle == profile

    return needle in {media_id, media_name, profile, overlay}


def _generate_media_filename(spec: dict[str, Any], secure_boot: bool, with_extension: bool = True) -> str:
    overlay = str(_pick_value(spec, "overlay") or "")
    profile = str(_pick_value(spec, "profile") or "")
    architecture = str(_pick_value(spec, "architecture") or "")
    extension = str(_pick_value(spec, "extension") or "")

    prefix = "metal" if overlay else profile
    filename = f"{prefix}-{architecture}"
    if secure_boot:
        filename += "-secureboot"
    if with_extension and extension:
        filename += f".{extension}"
    return filename


def _select_installation_media(
    client: Any,
    *,
    image_name: str,
    arch: str,
) -> dict[str, Any]:
    payload = client.resources.list(
        {
            "namespace": "ephemeral",
            "type": "InstallationMedias.omni.sidero.dev",
        }
    )
    items = _resource_items(payload)
    matched = [item for item in items if _matches_installation_media(item, image_name)]

    if len(matched) > 1:
        target_arch = arch.lower()
        by_arch = [
            item
            for item in matched
            if str(_pick_value(_as_spec(item), "architecture") or "").lower() == target_arch
        ]
        if by_arch:
            matched = by_arch

    if not matched:
        raise typer.BadParameter(f'no installation media found for "{image_name}"')
    if len(matched) > 1:
        ids = [str(_as_metadata(item).get("id", "")) for item in matched]
        raise typer.BadParameter(
            "multiple installation medias matched: " + ", ".join(ids) + " (refine with --arch)"
        )

    return matched[0]


def _print_payload(payload: Any, output: str = "table") -> None:
    if output == "json":
        console.print_json(json.dumps(payload, indent=2, default=str))
        return

    if output == "yaml":
        console.print(yaml.safe_dump(payload, sort_keys=False))
        return

    if isinstance(payload, dict) and "items" in payload and isinstance(payload["items"], list):
        table = Table(show_header=True, header_style="bold cyan")
        table.add_column("#")
        table.add_column("ID")
        table.add_column("Type")
        table.add_column("Namespace")
        for i, item in enumerate(payload["items"], 1):
            if isinstance(item, dict):
                md = item.get("metadata", {}) if isinstance(item.get("metadata"), dict) else {}
                table.add_row(str(i), str(md.get("id", "")), str(md.get("type", "")), str(md.get("namespace", "")))
            else:
                table.add_row(str(i), str(item), "", "")
        console.print(table)
        return

    console.print(payload)


_CONFIG_REDACTED = "<redacted>"
_SENSITIVE_CONFIG_KEY_MARKERS = (
    "token",
    "secret",
    "password",
    "passphrase",
    "private_key",
    "service_account_key",
    "cert",
    "authorization",
    "header",
    "cookie",
    "jwt",
    "signature",
)


def _is_sensitive_config_key(key: str) -> bool:
    lowered = key.lower()
    return any(marker in lowered for marker in _SENSITIVE_CONFIG_KEY_MARKERS)


def _redact_sensitive_config(value: Any) -> Any:
    if isinstance(value, dict):
        redacted: dict[str, Any] = {}
        for key, item in value.items():
            if _is_sensitive_config_key(str(key)):
                redacted[str(key)] = _CONFIG_REDACTED
            else:
                redacted[str(key)] = _redact_sensitive_config(item)
        return redacted

    if isinstance(value, list):
        return [_redact_sensitive_config(item) for item in value]

    return value


@app.callback()
def main(
    ctx: typer.Context,
    config_path: str | None = typer.Option(None, "--config", help="Path to Omni SDK config file"),
    instance: str | None = typer.Option(None, "--instance", help="Omni instance name"),
    cluster: str | None = typer.Option(
        None,
        "--cluster",
        help="Default cluster for this command",
        autocompletion=_complete_clusters,
    ),
) -> None:
    """omnictl CLI with omnictl-aligned command structure."""
    configure(config_path=config_path)
    ctx.obj = CLIContext(config_path=config_path, instance=instance, cluster=cluster)
    _record_history(ctx, instance=instance, cluster=cluster)


@config_app.command("info")
def config_info(ctx: typer.Context) -> None:
    cfg = load_config(config_path=getattr(ctx.obj, "config_path", None))
    payload = _redact_sensitive_config(cfg.data.model_dump(mode="json"))
    payload["_source"] = cfg.source
    payload["_path"] = str(cfg.path) if cfg.path else None
    manager = _talos_manager(ctx)
    payload["_state"] = {
        "history_count": manager.state.history_count(),
        "history_max_entries": HISTORY_MAX_ENTRIES,
        "talos": manager.summary(),
    }
    _print_payload(payload, output="yaml")


@config_app.command("contexts")
def config_contexts(ctx: typer.Context) -> None:
    cfg = load_config(config_path=getattr(ctx.obj, "config_path", None))
    table = Table(show_header=True, header_style="bold cyan")
    table.add_column("CURRENT")
    table.add_column("NAME")
    table.add_column("URL")
    for name, inst in cfg.data.instances.items():
        current = "*" if name == cfg.data.default_instance else ""
        table.add_row(current, name, inst.url)
    console.print(table)


@config_app.command("set-default-instance")
def config_set_default_instance(name: str) -> None:
    set_default_instance(name)
    console.print(f"default instance set to [bold]{name}[/bold]")


@config_app.command("set-default-cluster")
def config_set_default_cluster(name: str) -> None:
    set_default_cluster(name)
    console.print(f"default cluster set to [bold]{name}[/bold]")


@config_talos_app.command("info")
def config_talos_info(ctx: typer.Context) -> None:
    manager = _talos_manager(ctx)
    payload = manager.summary()
    payload["state_key_default_cluster"] = KEY_DEFAULT_CLUSTER
    _print_payload(payload, output="yaml")


@config_talos_app.command("set-default-cluster")
def config_talos_set_default_cluster(ctx: typer.Context, name: str) -> None:
    manager = _talos_manager(ctx)
    manager.set_default_cluster(name)
    manager.remember_cluster(name)
    console.print(f"talos default cluster set to [bold]{name}[/bold]")


@config_talos_app.command("set-talosconfig-path")
def config_talos_set_talosconfig_path(ctx: typer.Context, path: Path) -> None:
    manager = _talos_manager(ctx)
    resolved = manager.set_talosconfig_path_for_instance(path)
    console.print(f"talosconfig path for instance set to [bold]{resolved}[/bold]")


@config_talos_app.command("refresh")
def config_talos_refresh(
    ctx: typer.Context,
    cluster: str | None = typer.Option(None, "--cluster", "-c", autocompletion=_complete_clusters),
    break_glass: bool = typer.Option(False, "--break-glass"),
    ttl: int = typer.Option(DEFAULT_REFRESH_TTL_SECONDS, "--ttl"),
    talosconfig_path: Path | None = typer.Option(None, "--talosconfig-path"),
) -> None:
    manager = _talos_manager(ctx)
    cfg = ctx.obj if isinstance(ctx.obj, CLIContext) else CLIContext()
    resolved = _run_coro_sync(
        manager.ensure(
            cluster=cluster or cfg.cluster,
            break_glass=break_glass,
            force_refresh=True,
            ttl_seconds=ttl,
            path=talosconfig_path,
        )
    )
    console.print(
        f"refreshed talosconfig [bold]{resolved.path}[/bold] with context [bold]{resolved.context}[/bold]"
    )


@config_history_app.command("info")
def config_history_info(ctx: typer.Context) -> None:
    manager = _talos_manager(ctx)
    state = manager.state
    payload = {
        "enabled": True,
        "max_entries": HISTORY_MAX_ENTRIES,
        "count": state.history_count(),
    }
    _print_payload(payload, output="yaml")


@config_history_app.command("clear")
def config_history_clear(ctx: typer.Context) -> None:
    manager = _talos_manager(ctx)
    manager.state.history_clear()
    console.print("cleared command history")


@app.command(
    "talos",
    context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
    help="Thin talosctl wrapper with managed talosconfig",
)
@app.command(
    "t",
    context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
    help="Alias for talos",
)
def talos_proxy(
    ctx: typer.Context,
    cluster: str | None = typer.Option(
        None,
        "--cluster",
        "-c",
        help="Cluster for managed talos context",
        autocompletion=_complete_clusters,
    ),
    refresh: bool = typer.Option(False, "--refresh", help="Force talosconfig refresh"),
    ttl: int = typer.Option(DEFAULT_REFRESH_TTL_SECONDS, "--ttl", help="Refresh TTL in seconds"),
    break_glass: bool = typer.Option(False, "--break-glass", help="Request break-glass talosconfig"),
    talosconfig_path: Path | None = typer.Option(None, "--talosconfig-path", help="Managed talosconfig file path"),
    print_command: bool = typer.Option(False, "--print-command", help="Print final talosctl command"),
) -> None:
    passthrough = list(ctx.args)
    if not passthrough:
        raise typer.BadParameter("talos subcommand is required, e.g. 'omnipy talos get members'")

    talosctl = shutil.which("talosctl")
    if talosctl is None:
        raise typer.BadParameter("talosctl was not found in PATH")

    manager = _talos_manager(ctx)
    cfg = ctx.obj if isinstance(ctx.obj, CLIContext) else CLIContext()
    _record_history(
        ctx,
        instance=cfg.instance,
        cluster=cluster or cfg.cluster,
        args_override=["talos", *passthrough],
    )

    resolved = _run_coro_sync(
        manager.ensure(
            cluster=cluster or cfg.cluster,
            break_glass=break_glass,
            force_refresh=refresh,
            ttl_seconds=ttl,
            path=talosconfig_path,
        )
    )

    if resolved.cluster:
        manager.remember_cluster(resolved.cluster)

    saved_nodes, _ = manager.get_last_targets(cluster=resolved.cluster)
    passthrough, explicit_nodes = _resolve_talos_nodes_passthrough(
        manager,
        passthrough,
        cluster=resolved.cluster,
        saved_nodes=saved_nodes,
    )
    explicit_endpoints = _arg_extract_value(passthrough, "--endpoints", "-e")
    manager.set_last_targets(
        cluster=resolved.cluster,
        nodes=explicit_nodes,
        endpoints=explicit_endpoints,
    )

    saved_nodes, saved_endpoints = manager.get_last_targets(cluster=resolved.cluster)

    command = [talosctl]
    if not _arg_has_flag(passthrough, "--talosconfig"):
        command.extend(["--talosconfig", str(resolved.path)])
    if not _arg_has_flag(passthrough, "--context"):
        command.extend(["--context", resolved.context])
    if saved_nodes and not _arg_has_flag(passthrough, "--nodes", "-n"):
        command.extend(["--nodes", saved_nodes])
    if saved_endpoints and not _arg_has_flag(passthrough, "--endpoints", "-e"):
        command.extend(["--endpoints", saved_endpoints])

    command.extend(passthrough)
    if print_command:
        console.print(" ".join(shlex.quote(part) for part in command))

    subprocess_env: dict[str, str] | None = None
    if not _arg_has_flag(passthrough, "--siderov1-keys-dir"):
        has_service_account_env = bool(os.getenv("OMNI_SERVICE_ACCOUNT_KEY") or os.getenv("SIDERO_SERVICE_ACCOUNT_KEY"))
        if not has_service_account_env:
            service_account_key = manager.service_account_key_b64()
            if service_account_key:
                subprocess_env = dict(os.environ)
                subprocess_env["OMNI_SERVICE_ACCOUNT_KEY"] = service_account_key

    result = subprocess.run(command, check=False, env=subprocess_env)
    if result.returncode != 0 and not _arg_has_flag(passthrough, "--nodes", "-n") and not saved_nodes:
        console.print(
            "note: no default Talos nodes are configured for this context. "
            "run once with --nodes to persist defaults for future commands."
        )
    raise typer.Exit(code=int(result.returncode))


@crd_app.command("list")
def crd_list(
    query: str | None = typer.Option(None, "--query", "-q", help="Filter by alias or type"),
    output: str = typer.Option("table", "--output", "-o"),
) -> None:
    resolver = crd_resolver_factory()
    items = resolver.list()
    if query:
        needle = query.strip().lower()
        items = [
            item
            for item in items
            if needle in item.resource_type.lower() or any(needle in alias.lower() for alias in item.aliases)
        ]

    if output in {"json", "yaml"}:
        payload = [_crd_payload(item) for item in items]
        _print_payload(payload, output=output)
        return

    table = Table(show_header=True, header_style="bold cyan")
    table.add_column("TYPE")
    table.add_column("DEFAULT NS")
    table.add_column("ALIASES")
    table.add_column("DESCRIPTION")
    for item in items:
        aliases = ", ".join(item.aliases[:4]) if item.aliases else ""
        table.add_row(
            item.resource_type,
            item.default_namespace or "default",
            aliases,
            item.description[:120],
        )
    console.print(table)


@crd_app.command("show")
def crd_show(
    ctx: typer.Context,
    type_or_alias: str = typer.Argument(..., help="Resource type or known alias"),
    output: str = typer.Option("yaml", "--output", "-o"),
) -> None:
    resolver = crd_resolver_factory()
    client = _ctx_client(ctx)
    info = resolver.resolve(type_or_alias, client=client)
    if info is None:
        raise typer.BadParameter(f"unknown resource type or alias: {type_or_alias}")
    _print_payload(_crd_payload(info), output=output)


@crd_app.command("explain")
def crd_explain(
    ctx: typer.Context,
    type_or_alias: str = typer.Argument(..., help="Resource type or known alias"),
) -> None:
    resolver = crd_resolver_factory()
    client = _ctx_client(ctx)
    console.print(resolver.explain(type_or_alias, client=client))


@app.command("get")
def get_resource(
    ctx: typer.Context,
    resource_type: str = typer.Argument(..., help="Resource type", autocompletion=_complete_resource_types),
    resource_id: str | None = typer.Argument(None, help="Resource ID"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("table", "--output", "-o"),
    explain: bool = typer.Option(False, "--explain", help="Explain resource type before output"),
) -> None:
    client = _ctx_client(ctx)
    cfg = ctx.obj if isinstance(ctx.obj, CLIContext) else CLIContext()
    command_args = ["get", resource_type]
    if resource_id:
        command_args.append(resource_id)
    _record_history(ctx, instance=cfg.instance, cluster=cfg.cluster, args_override=command_args)
    if explain:
        resolver = crd_resolver_factory()
        console.print(resolver.explain(resource_type, client=client))
        console.print("")
    if resource_id:
        payload = client.resources.get({"namespace": namespace, "type": resource_type, "id": resource_id})
        _print_payload(_decode_embedded_json(payload), output=output)
        return

    payload = client.resources.list({"namespace": namespace, "type": resource_type})
    _print_payload(_decode_list_items(payload), output=output)


@app.command("watch")
def watch_resource(
    ctx: typer.Context,
    resource_type: str = typer.Argument(..., help="Resource type", autocompletion=_complete_resource_types),
    resource_id: str | None = typer.Argument(None, help="Resource ID"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    cfg = ctx.obj if isinstance(ctx.obj, CLIContext) else CLIContext()
    command_args = ["watch", resource_type]
    if resource_id:
        command_args.append(resource_id)
    _record_history(ctx, instance=cfg.instance, cluster=cfg.cluster, args_override=command_args)
    request = {"namespace": namespace, "type": resource_type, "id": resource_id or ""}
    for event in client.resources.watch(request):
        _print_payload(event, output=output)


@app.command("delete")
def delete_resource(
    ctx: typer.Context,
    resource_type: str = typer.Argument(..., help="Resource type", autocompletion=_complete_resource_types),
    resource_id: str = typer.Argument(..., help="Resource ID"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    teardown: bool = typer.Option(False, "--teardown", help="Use Teardown instead of Delete"),
) -> None:
    client = _ctx_client(ctx)
    cfg = ctx.obj if isinstance(ctx.obj, CLIContext) else CLIContext()
    _record_history(
        ctx,
        instance=cfg.instance,
        cluster=cfg.cluster,
        args_override=["delete", resource_type, resource_id],
    )
    req = {"namespace": namespace, "type": resource_type, "id": resource_id}
    if teardown:
        payload = client.resources.teardown(req)
    else:
        payload = client.resources.delete(req)
    _print_payload(payload, output="json")


@app.command("apply")
def apply_resources(
    ctx: typer.Context,
    file: Path = typer.Option(..., "--file", "-f", exists=True),
    dry_run: bool = typer.Option(False, "--dry-run", "-d"),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
    client = _ctx_client(ctx)
    docs = list(yaml.safe_load_all(file.read_text(encoding="utf-8")))
    for doc in docs:
        if not isinstance(doc, dict):
            continue

        raw_metadata = doc.get("metadata")
        md: dict[str, Any] = raw_metadata if isinstance(raw_metadata, dict) else {}
        namespace = str(md.get("namespace", "default"))
        resource_type = str(md.get("type", ""))
        resource_id = str(md.get("id", ""))
        if not resource_type or not resource_id:
            raise typer.BadParameter("each document must include metadata.type and metadata.id")

        spec = doc.get("spec", {})
        req = {
            "resource": {
                "metadata": {
                    "namespace": namespace,
                    "type": resource_type,
                    "id": resource_id,
                },
                "spec": json.dumps(spec),
            }
        }

        if dry_run:
            if verbose:
                console.print(f"[yellow]dry-run[/yellow] would apply {resource_type}/{resource_id}")
            continue

        try:
            existing = client.resources.get(
                {"namespace": namespace, "type": resource_type, "id": resource_id}
            )
            current = _decode_embedded_json(existing)
            current_version = (
                str(current.get("metadata", {}).get("version", ""))
                if isinstance(current.get("metadata"), dict)
                else ""
            )
            client.resources.update({"currentVersion": current_version, **req})
            action = "updated"
        except OmniHTTPError as exc:
            if exc.status_code == 404:
                client.resources.create(req)
                action = "created"
            else:
                raise

        if verbose:
            console.print(f"[green]{action}[/green] {resource_type}/{resource_id}")


@app.command("kubeconfig")
def kubeconfig(
    ctx: typer.Context,
    cluster: str | None = typer.Option(None, "--cluster", "-c", autocompletion=_complete_clusters),
    service_account: bool = typer.Option(False, "--service-account"),
    service_account_user: str = typer.Option("", "--user"),
    service_account_groups: list[str] = typer.Option([], "--groups"),
    service_account_ttl: int = typer.Option(365 * 24 * 3600, "--ttl", help="Service account token TTL in seconds"),
    grant_type: str = typer.Option("", "--grant-type"),
    break_glass: bool = typer.Option(False, "--break-glass"),
    output: str = typer.Option("yaml", "--output", "-o"),
) -> None:
    if service_account and not service_account_user:
        raise typer.BadParameter("--user is required when --service-account is set")

    client = _ctx_client(ctx)
    request: dict[str, Any] = {
        "service_account": service_account,
        "break_glass": break_glass,
        "grant_type": grant_type,
    }
    if service_account:
        request["service_account_user"] = service_account_user
        request["service_account_groups"] = service_account_groups
        request["service_account_ttl"] = f"{max(1, service_account_ttl)}s"

    metadata = {"cluster": cluster} if cluster else None
    payload = client.management.kubeconfig(request, metadata=metadata)

    content = _decode_bytes_field(payload, "kubeconfig")
    if content:
        text = content.decode("utf-8", errors="replace")
        if output == "json":
            _print_payload(payload, output=output)
        elif output == "yaml":
            console.print(text)
        else:
            console.print(text)
        return

    _print_payload(payload, output=output)


@app.command("talosconfig")
def talosconfig(
    ctx: typer.Context,
    cluster: str | None = typer.Option(None, "--cluster", "-c", autocompletion=_complete_clusters),
    raw: bool = typer.Option(False, "--raw"),
    break_glass: bool = typer.Option(False, "--break-glass"),
    output: str = typer.Option("yaml", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    request = {"raw": raw, "break_glass": break_glass}
    metadata = {"cluster": cluster} if cluster else None
    payload = client.management.talosconfig(request, metadata=metadata)

    content = _decode_bytes_field(payload, "talosconfig")
    if content:
        text = content.decode("utf-8", errors="replace")
        if output == "json":
            _print_payload(payload, output=output)
        elif output == "yaml":
            console.print(text)
        else:
            console.print(text)
        return

    _print_payload(payload, output=output)


@app.command("omniconfig")
def omniconfig(
    ctx: typer.Context,
    output: str = typer.Option("yaml", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.management.omniconfig({})
    content = _decode_bytes_field(payload, "omniconfig")
    if content:
        text = content.decode("utf-8", errors="replace")
        if output == "json":
            _print_payload(payload, output=output)
        elif output == "yaml":
            console.print(text)
        else:
            console.print(text)
        return
    _print_payload(payload, output=output)


@app.command("machine-logs")
def machine_logs(
    ctx: typer.Context,
    machine_id: str = typer.Argument(..., help="Machine ID"),
    follow: bool = typer.Option(False, "--follow"),
    tail_lines: int = typer.Option(200, "--tail-lines"),
) -> None:
    client = _ctx_client(ctx)
    req = {"machine_id": machine_id, "follow": follow, "tail_lines": tail_lines}
    for event in client.management.machine_logs(req):
        if isinstance(event, dict) and "bytes" in event:
            console.print(event["bytes"])
        else:
            _print_payload(event, output="json")


@app.command("audit-log")
def audit_log(
    ctx: typer.Context,
    start_time: str | None = typer.Argument(None, help="Start date in YYYY-MM-DD"),
    end_time: str | None = typer.Argument(None, help="End date in YYYY-MM-DD"),
) -> None:
    client = _ctx_client(ctx)
    request = {"start_time": start_time or "", "end_time": end_time or ""}

    for event in client.management.read_audit_log(request):
        data = _decode_bytes_field(event, "audit_log", "auditLog")
        if data:
            console.print(data.decode("utf-8", errors="replace"), end="")
            continue
        _print_payload(event, output="json")


@app.command("support")
def support_bundle(
    ctx: typer.Context,
    cluster: str | None = typer.Option(None, "--cluster", "-c", autocompletion=_complete_clusters),
    output: Path = typer.Option(Path("support.zip"), "--output", "-O"),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
    force: bool = typer.Option(False, "--force", "-f"),
) -> None:
    client = _ctx_client(ctx)
    resolved_cluster = _require_cluster(ctx, cluster)
    output_path = output.expanduser()

    if output_path.exists() and not force:
        raise typer.BadParameter(f"{output_path} already exists (use --force to overwrite)")

    bundle = bytearray()
    request = {"cluster": resolved_cluster}
    for event in client.management.get_support_bundle(request):
        progress = _pick_value(event, "progress")
        if verbose and isinstance(progress, dict):
            source = str(_pick_value(progress, "source") or "")
            state = str(_pick_value(progress, "state") or "")
            total = str(_pick_value(progress, "total") or "")
            value = str(_pick_value(progress, "value") or "")
            error = str(_pick_value(progress, "error") or "")
            if error:
                console.print(f"[red]{source}[/red]: {error}")
            else:
                console.print(f"{source}: {state} ({value}/{total})")

        chunk = _decode_bytes_field(event, "bundle_data", "bundleData")
        if chunk:
            bundle.extend(chunk)

    if not bundle:
        raise typer.Exit(code=1)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_bytes(bytes(bundle))
    console.print(f"wrote support bundle to [bold]{output_path}[/bold]")


@app.command("download")
def download_media(
    ctx: typer.Context,
    image_name: str | None = typer.Argument(None, help="Image name/profile/id to download"),
    list_only: bool = typer.Option(False, "--list", help="List available installation media"),
    arch: str = typer.Option("amd64", "--arch", help="Target architecture"),
    output: Path = typer.Option(Path("."), "--output", help="Output file or directory"),
    talos_version: str = typer.Option("", "--talos-version", help="Talos version for generated media"),
    pxe: bool = typer.Option(False, "--pxe", help="Print PXE URL and exit"),
    secure_boot: bool = typer.Option(False, "--secureboot", help="Request secure boot media"),
    extensions: list[str] = typer.Option([], "--extensions", help="Extension names"),
    extra_kernel_args: list[str] = typer.Option([], "--extra-kernel-args", help="Extra kernel args"),
    use_siderolink_grpc_tunnel: bool = typer.Option(False, "--use-siderolink-grpc-tunnel"),
    disable_siderolink_grpc_tunnel: bool = typer.Option(False, "--disable-siderolink-grpc-tunnel"),
    force: bool = typer.Option(False, "--force", "-f"),
    dry_run: bool = typer.Option(False, "--dry-run"),
) -> None:
    client = _ctx_client(ctx)

    list_payload = client.resources.list(
        {
            "namespace": "ephemeral",
            "type": "InstallationMedias.omni.sidero.dev",
        }
    )
    items = _resource_items(list_payload)

    if list_only:
        table = Table(show_header=True, header_style="bold cyan")
        table.add_column("ID")
        table.add_column("NAME")
        table.add_column("PROFILE")
        table.add_column("ARCH")
        table.add_column("OVERLAY")
        table.add_column("EXT")
        for item in items:
            metadata = _as_metadata(item)
            spec = _as_spec(item)
            table.add_row(
                str(metadata.get("id", "")),
                str(_pick_value(spec, "name") or ""),
                str(_pick_value(spec, "profile") or ""),
                str(_pick_value(spec, "architecture") or ""),
                str(_pick_value(spec, "overlay") or ""),
                str(_pick_value(spec, "extension") or ""),
            )
        console.print(table)
        return

    if not image_name:
        raise typer.BadParameter("image_name is required unless --list is used")

    if use_siderolink_grpc_tunnel and disable_siderolink_grpc_tunnel:
        raise typer.BadParameter(
            "--use-siderolink-grpc-tunnel and --disable-siderolink-grpc-tunnel are mutually exclusive"
        )

    media = _select_installation_media(client, image_name=image_name, arch=arch)
    media_metadata = _as_metadata(media)
    media_spec = _as_spec(media)
    media_id = str(media_metadata.get("id", ""))
    if not media_id:
        raise typer.BadParameter("selected installation media has no ID")

    tunnel_mode = 0
    if use_siderolink_grpc_tunnel:
        tunnel_mode = 2
    elif disable_siderolink_grpc_tunnel:
        tunnel_mode = 1

    schematic_request: dict[str, Any] = {
        "media_id": media_id,
        "secure_boot": secure_boot,
        "extensions": extensions,
        "extra_kernel_args": extra_kernel_args,
    }
    if talos_version:
        schematic_request["talos_version"] = talos_version
    if tunnel_mode:
        schematic_request["siderolink_grpc_tunnel_mode"] = tunnel_mode

    if dry_run:
        _print_payload(
            {
                "media": media,
                "create_schematic_request": schematic_request,
            },
            output="yaml",
        )
        return

    schematic = client.management.create_schematic(schematic_request)
    schematic_id = _pick_value(schematic, "schematic_id", "schematicId")
    if not isinstance(schematic_id, str) or not schematic_id:
        _print_payload(schematic, output="json")
        raise typer.Exit(code=1)

    if pxe:
        pxe_url = _pick_value(schematic, "pxe_url", "pxeUrl")
        if isinstance(pxe_url, str) and pxe_url:
            console.print(pxe_url)
            return
        _print_payload(schematic, output="json")
        return

    if not talos_version:
        raise typer.BadParameter("--talos-version is required for download")

    filename = _generate_media_filename(media_spec, secure_boot, with_extension=True)
    query = "secureboot=true" if secure_boot else ""
    request_path = f"/image/{schematic_id}/{talos_version}/{filename}"
    if query:
        request_path = f"{request_path}?{query}"

    dest = output.expanduser()
    extension = str(_pick_value(media_spec, "extension") or "")
    if dest.suffix == "":
        dest.mkdir(parents=True, exist_ok=True)
        dest_prefix = str(_pick_value(media_spec, "dest_file_prefix", "destFilePrefix") or media_id)
        dest_name = f"{dest_prefix}-{talos_version}-{schematic_id[:6]}"
        if extension:
            dest_name += f".{extension}"
        dest = dest / dest_name
    else:
        dest.parent.mkdir(parents=True, exist_ok=True)

    if dest.exists() and not force:
        raise typer.BadParameter(f"{dest} already exists (use --force to overwrite)")

    features_payload = client.resources.get(
        {
            "namespace": "default",
            "type": "FeaturesConfigs.omni.sidero.dev",
            "id": "features-config",
        }
    )
    features = _decode_embedded_json(features_payload)
    features_spec = _as_spec(features)
    image_factory_base = _pick_value(features_spec, "image_factory_base_url", "imageFactoryBaseUrl")

    response: httpx.Response
    if isinstance(image_factory_base, str) and image_factory_base:
        if urlsplit(image_factory_base).netloc == urlsplit(client._async.instance_config.url).netloc:
            response = _run_coro_sync(client._async._transport.image_request(path=request_path))
        else:
            signer = client._async._transport.signer
            headers: dict[str, str] = {}
            if signer is not None:
                headers.update(
                    signer.sign_image_request(http_method="GET", request_uri=request_path, body=b"").headers
                )
            response = httpx.get(
                image_factory_base.rstrip("/") + request_path,
                headers=headers,
                timeout=120.0,
                follow_redirects=True,
                verify=client._async.instance_config.verify_tls,
            )
    else:
        response = _run_coro_sync(client._async._transport.image_request(path=request_path))

    if response.status_code >= 400:
        raise typer.BadParameter(
            f"download failed with status {response.status_code}: {response.text[:200]}"
        )

    dest.write_bytes(response.content)
    console.print(f"downloaded [bold]{media_id}[/bold] to [bold]{dest}[/bold]")


@serviceaccount_app.command("list")
def serviceaccount_list(ctx: typer.Context, output: str = typer.Option("table", "--output", "-o")) -> None:
    client = _ctx_client(ctx)
    payload = client.management.list_service_accounts()
    _print_payload(payload, output=output)


@serviceaccount_app.command("create")
def serviceaccount_create(
    ctx: typer.Context,
    name: str = typer.Argument(...),
    armored_pgp_public_key: str = typer.Option(..., "--armored-pgp-public-key"),
    role: str = typer.Option("Operator", "--role"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.management.create_service_account(
        {
            "name": name,
            "armored_pgp_public_key": armored_pgp_public_key,
            "role": role,
            "use_user_role": False,
        }
    )
    _print_payload(payload, output="json")


@serviceaccount_app.command("renew")
def serviceaccount_renew(
    ctx: typer.Context,
    name: str = typer.Argument(...),
    armored_pgp_public_key: str = typer.Option(..., "--armored-pgp-public-key"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.management.renew_service_account(
        {
            "name": name,
            "armored_pgp_public_key": armored_pgp_public_key,
        }
    )
    _print_payload(payload, output="json")


@serviceaccount_app.command("destroy")
def serviceaccount_destroy(ctx: typer.Context, name: str = typer.Argument(...)) -> None:
    client = _ctx_client(ctx)
    payload = client.management.destroy_service_account({"name": name})
    _print_payload(payload, output="json")


@cluster_app.command("status")
def cluster_status(
    ctx: typer.Context,
    cluster_name: str = typer.Argument(...),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.resources.get(
        {
            "namespace": namespace,
            "type": "ClusterStatuses.omni.sidero.dev",
            "id": cluster_name,
        }
    )
    _print_payload(_decode_embedded_json(payload), output=output)


def _update_resource_annotations(
    client: Any,
    *,
    namespace: str,
    resource_type: str,
    resource_id: str,
    mutator: Any,
) -> dict[str, Any]:
    fetched = client.resources.get({"namespace": namespace, "type": resource_type, "id": resource_id})
    current = _decode_embedded_json(fetched)
    metadata = _as_metadata(current)
    spec = _as_spec(current)

    annotations = metadata.get("annotations")
    if not isinstance(annotations, dict):
        annotations = {}

    mutator(annotations)
    metadata["annotations"] = annotations

    req = {
        "currentVersion": str(metadata.get("version", "")),
        "resource": {
            "metadata": _metadata_for_update(metadata),
            "spec": json.dumps(spec),
        },
    }
    return client.resources.update(req)


@cluster_app.command("lock")
def cluster_lock(
    ctx: typer.Context,
    cluster_name: str = typer.Argument(...),
    namespace: str = typer.Option("default", "--namespace", "-n"),
) -> None:
    client = _ctx_client(ctx)

    def lock_mutation(annotations: dict[str, Any]) -> None:
        annotations["omni.sidero.dev/cluster-locked"] = ""

    payload = _update_resource_annotations(
        client,
        namespace=namespace,
        resource_type="Clusters.omni.sidero.dev",
        resource_id=cluster_name,
        mutator=lock_mutation,
    )
    _print_payload(payload, output="json")


@cluster_app.command("unlock")
def cluster_unlock(
    ctx: typer.Context,
    cluster_name: str = typer.Argument(...),
    namespace: str = typer.Option("default", "--namespace", "-n"),
) -> None:
    client = _ctx_client(ctx)

    def unlock_mutation(annotations: dict[str, Any]) -> None:
        annotations.pop("omni.sidero.dev/cluster-locked", None)
        annotations.pop("omni.sidero.dev/cluster-import-is-in-progress", None)

    payload = _update_resource_annotations(
        client,
        namespace=namespace,
        resource_type="Clusters.omni.sidero.dev",
        resource_id=cluster_name,
        mutator=unlock_mutation,
    )
    _print_payload(payload, output="json")


@cluster_app.command("delete")
def cluster_delete(
    ctx: typer.Context,
    cluster_name: str = typer.Argument(...),
    namespace: str = typer.Option("default", "--namespace", "-n"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.resources.teardown(
        {
            "namespace": namespace,
            "type": "Clusters.omni.sidero.dev",
            "id": cluster_name,
        }
    )
    _print_payload(payload, output="json")


@cluster_kubernetes_app.command("upgrade-pre-checks")
def cluster_kubernetes_upgrade_pre_checks(
    ctx: typer.Context,
    cluster_name: str = typer.Argument(..., help="Cluster name"),
    to_version: str = typer.Option(..., "--to", help="Target Kubernetes version"),
    output: str = typer.Option("yaml", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.management.kubernetes_upgrade_pre_checks(
        {"new_version": to_version},
        metadata={"cluster": cluster_name},
    )
    ok = _pick_value(payload, "ok")
    reason = _pick_value(payload, "reason")

    if output == "table":
        status = "ok" if bool(ok) else "not-ok"
        console.print(f"cluster={cluster_name} status={status} reason={reason or ''}")
        return

    _print_payload(payload, output=output)


@cluster_kubernetes_app.command("manifest-sync")
def cluster_kubernetes_manifest_sync(
    ctx: typer.Context,
    cluster_name: str = typer.Argument(..., help="Cluster name"),
    dry_run: bool = typer.Option(True, "--dry-run/--no-dry-run"),
    output: str = typer.Option("table", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    request = {"dry_run": dry_run}
    states = {
        0: "UNKNOWN",
        1: "MANIFEST",
        2: "ROLLOUT",
    }

    if output in {"json", "yaml"}:
        results = []
        for event in client.management.kubernetes_sync_manifests(
            request,
            metadata={"cluster": cluster_name},
        ):
            results.append(event)
        _print_payload({"items": results}, output=output)
        return

    for event in client.management.kubernetes_sync_manifests(
        request,
        metadata={"cluster": cluster_name},
    ):
        response_type = _pick_value(event, "response_type", "responseType")
        response_name = states.get(response_type, str(response_type))
        path = str(_pick_value(event, "path") or "")
        diff = str(_pick_value(event, "diff") or "")
        skipped = bool(_pick_value(event, "skipped") or False)

        if response_name == "MANIFEST":
            console.print(f" > processing manifest {path}")
            if skipped:
                console.print(" < no changes")
            elif dry_run:
                if diff:
                    console.print(diff)
                console.print(" < dry run, change skipped")
            else:
                if diff:
                    console.print(diff)
                console.print(" < applied successfully")
            continue

        if response_name == "ROLLOUT":
            console.print(f" > waiting for {path}")
            continue

        _print_payload(event, output="json")


@cluster_machine_app.command("delete")
def cluster_machine_delete(
    ctx: typer.Context,
    machine_id: str = typer.Argument(...),
    namespace: str = typer.Option("default", "--namespace", "-n"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.resources.teardown(
        {
            "namespace": namespace,
            "type": "ClusterMachines.omni.sidero.dev",
            "id": machine_id,
        }
    )
    _print_payload(payload, output="json")


def _set_machine_lock(
    client: Any,
    machine_id: str,
    namespace: str,
    lock_state: bool,
) -> dict[str, Any]:
    def lock_mutation(annotations: dict[str, Any]) -> None:
        if lock_state:
            annotations["omni.sidero.dev/locked"] = ""
        else:
            annotations.pop("omni.sidero.dev/locked", None)

    return _update_resource_annotations(
        client,
        namespace=namespace,
        resource_type="MachineSetNodes.omni.sidero.dev",
        resource_id=machine_id,
        mutator=lock_mutation,
    )


@cluster_machine_app.command("lock")
def cluster_machine_lock(
    ctx: typer.Context,
    machine_id: str = typer.Argument(...),
    namespace: str = typer.Option("default", "--namespace", "-n"),
) -> None:
    client = _ctx_client(ctx)
    payload = _set_machine_lock(client, machine_id, namespace, True)
    _print_payload(payload, output="json")


@cluster_machine_app.command("unlock")
def cluster_machine_unlock(
    ctx: typer.Context,
    machine_id: str = typer.Argument(...),
    namespace: str = typer.Option("default", "--namespace", "-n"),
) -> None:
    client = _ctx_client(ctx)
    payload = _set_machine_lock(client, machine_id, namespace, False)
    _print_payload(payload, output="json")


def _resolve_join_token_id(
    client: Any,
    *,
    namespace: str,
    token_id: str | None,
    token_name: str | None,
) -> str:
    if token_id:
        client.resources.get({"namespace": namespace, "type": "JoinTokens.omni.sidero.dev", "id": token_id})
        return token_id

    if token_name:
        listed = client.resources.list({"namespace": namespace, "type": "JoinTokenStatuses.omni.sidero.dev"})
        decoded = _decode_list_items(listed)
        items = decoded.get("items", [])
        if not isinstance(items, list):
            raise typer.BadParameter("join token status list response is invalid")

        for item in items:
            if not isinstance(item, dict):
                continue
            metadata = _as_metadata(item)
            spec = _as_spec(item)
            name = str(_pick_value(spec, "name") or "")

            if name == token_name or (token_name == "initial" and name == ""):
                resolved = metadata.get("id")
                if isinstance(resolved, str) and resolved:
                    return resolved

        raise typer.BadParameter(f'join token with name "{token_name}" was not found')

    default_token = client.resources.get(
        {"namespace": namespace, "type": "DefaultJoinTokens.omni.sidero.dev", "id": "default"}
    )
    default_decoded = _decode_embedded_json(default_token)
    default_spec = _as_spec(default_decoded)
    resolved = _pick_value(default_spec, "token_id", "tokenId")
    if not isinstance(resolved, str) or not resolved:
        raise typer.BadParameter("default join token is not configured")
    return resolved


@jointoken_app.command("create")
def jointoken_create(
    ctx: typer.Context,
    name: str = typer.Argument(..., help="Join token name"),
    ttl: int = typer.Option(0, "--ttl", "-t", help="TTL in seconds"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    request: dict[str, Any] = {"name": name}
    if ttl > 0:
        request["expiration_time"] = _rfc3339_after_seconds(ttl)

    payload = client.management.create_join_token(request)
    token_id = _pick_value(payload, "id")

    if output == "table" and isinstance(token_id, str) and token_id:
        console.print(token_id)
        return

    _print_payload(payload, output=output)


@jointoken_app.command("list")
def jointoken_list(
    ctx: typer.Context,
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("table", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.resources.list({"namespace": namespace, "type": "JoinTokenStatuses.omni.sidero.dev"})
    decoded = _decode_list_items(payload)

    if output != "table":
        _print_payload(decoded, output=output)
        return

    states = {
        0: "UNKNOWN",
        1: "ACTIVE",
        2: "REVOKED",
        3: "EXPIRED",
    }

    table = Table(show_header=True, header_style="bold cyan")
    table.add_column("ID")
    table.add_column("NAME")
    table.add_column("STATE")
    table.add_column("EXPIRATION")
    table.add_column("USE COUNT")
    table.add_column("DEFAULT")

    items = decoded.get("items", [])
    if isinstance(items, list):
        for item in items:
            if not isinstance(item, dict):
                continue
            metadata = _as_metadata(item)
            spec = _as_spec(item)

            state_value = _pick_value(spec, "state")
            if isinstance(state_value, int):
                state = states.get(state_value, str(state_value))
            else:
                state = str(state_value or "")

            expiration = str(_pick_value(spec, "expiration_time", "expirationTime") or "never")
            name = str(_pick_value(spec, "name") or "")
            use_count = str(_pick_value(spec, "use_count", "useCount") or 0)
            is_default = _pick_value(spec, "is_default", "isDefault")
            default_marker = "*" if bool(is_default) else ""
            table.add_row(str(metadata.get("id", "")), name, state, expiration, use_count, default_marker)

    console.print(table)


@jointoken_app.command("revoke")
def jointoken_revoke(
    ctx: typer.Context,
    token_id: str = typer.Argument(..., help="Join token ID"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    fetched = client.resources.get({"namespace": namespace, "type": "JoinTokens.omni.sidero.dev", "id": token_id})
    resource = _decode_embedded_json(fetched)
    spec = _as_spec(resource)
    spec["revoked"] = True
    payload = _update_resource_spec(
        client,
        namespace=namespace,
        resource_type="JoinTokens.omni.sidero.dev",
        resource_id=token_id,
        spec=spec,
    )
    _print_payload(payload, output=output)


@jointoken_app.command("unrevoke")
def jointoken_unrevoke(
    ctx: typer.Context,
    token_id: str = typer.Argument(..., help="Join token ID"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    fetched = client.resources.get({"namespace": namespace, "type": "JoinTokens.omni.sidero.dev", "id": token_id})
    resource = _decode_embedded_json(fetched)
    spec = _as_spec(resource)
    spec["revoked"] = False
    payload = _update_resource_spec(
        client,
        namespace=namespace,
        resource_type="JoinTokens.omni.sidero.dev",
        resource_id=token_id,
        spec=spec,
    )
    _print_payload(payload, output=output)


@jointoken_app.command("renew")
def jointoken_renew(
    ctx: typer.Context,
    token_id: str = typer.Argument(..., help="Join token ID"),
    ttl: int = typer.Option(..., "--ttl", "-t", help="TTL in seconds"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    if ttl <= 0:
        raise typer.BadParameter("--ttl must be greater than zero")

    client = _ctx_client(ctx)
    fetched = client.resources.get({"namespace": namespace, "type": "JoinTokens.omni.sidero.dev", "id": token_id})
    resource = _decode_embedded_json(fetched)
    spec = _as_spec(resource)
    spec["expiration_time"] = _rfc3339_after_seconds(ttl)
    payload = _update_resource_spec(
        client,
        namespace=namespace,
        resource_type="JoinTokens.omni.sidero.dev",
        resource_id=token_id,
        spec=spec,
    )
    _print_payload(payload, output=output)


@jointoken_app.command("make-default")
def jointoken_make_default(
    ctx: typer.Context,
    token_id: str = typer.Argument(..., help="Join token ID"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    client.resources.get({"namespace": namespace, "type": "JoinTokens.omni.sidero.dev", "id": token_id})
    spec = {"token_id": token_id}

    try:
        fetched = client.resources.get(
            {"namespace": namespace, "type": "DefaultJoinTokens.omni.sidero.dev", "id": "default"}
        )
        resource = _decode_embedded_json(fetched)
        md = _as_metadata(resource)
        req = {
            "currentVersion": str(md.get("version", "")),
            "resource": {
                "metadata": _metadata_for_update(md),
                "spec": json.dumps(spec),
            },
        }
        payload = client.resources.update(req)
    except OmniHTTPError as exc:
        if exc.status_code != 404:
            raise
        payload = client.resources.create(
            {
                "resource": {
                    "metadata": {
                        "namespace": namespace,
                        "type": "DefaultJoinTokens.omni.sidero.dev",
                        "id": "default",
                    },
                    "spec": json.dumps(spec),
                }
            }
        )

    _print_payload(payload, output=output)


@jointoken_app.command("machine-config")
def jointoken_machine_config(
    ctx: typer.Context,
    token_id: str | None = typer.Option(None, "--token-id"),
    token_name: str | None = typer.Option(None, "--token-name"),
    use_grpc_tunnel: bool = typer.Option(False, "--use-grpc-tunnel"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
) -> None:
    if token_id and token_name:
        raise typer.BadParameter("--token-id and --token-name are mutually exclusive")

    client = _ctx_client(ctx)
    resolved = _resolve_join_token_id(
        client,
        namespace=namespace,
        token_id=token_id,
        token_name=token_name,
    )

    payload = client.management.get_machine_join_config(
        {"join_token": resolved, "use_grpc_tunnel": use_grpc_tunnel}
    )
    config = _pick_value(payload, "config")
    if isinstance(config, str) and config:
        console.print(config)
        return
    _print_payload(payload, output="json")


@jointoken_app.command("kernel-args")
def jointoken_kernel_args(
    ctx: typer.Context,
    token_id: str | None = typer.Option(None, "--token-id"),
    token_name: str | None = typer.Option(None, "--token-name"),
    use_grpc_tunnel: bool = typer.Option(False, "--use-grpc-tunnel"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
) -> None:
    if token_id and token_name:
        raise typer.BadParameter("--token-id and --token-name are mutually exclusive")

    client = _ctx_client(ctx)
    resolved = _resolve_join_token_id(
        client,
        namespace=namespace,
        token_id=token_id,
        token_name=token_name,
    )

    payload = client.management.get_machine_join_config(
        {"join_token": resolved, "use_grpc_tunnel": use_grpc_tunnel}
    )
    kernel_args = _pick_value(payload, "kernel_args", "kernelArgs")
    if isinstance(kernel_args, list):
        console.print(" ".join(str(item) for item in kernel_args))
        return
    _print_payload(payload, output="json")


@jointoken_app.command("omni-endpoint")
def jointoken_omni_endpoint(
    ctx: typer.Context,
    token_id: str | None = typer.Option(None, "--token-id"),
    token_name: str | None = typer.Option(None, "--token-name"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
) -> None:
    if token_id and token_name:
        raise typer.BadParameter("--token-id and --token-name are mutually exclusive")

    client = _ctx_client(ctx)
    resolved = _resolve_join_token_id(
        client,
        namespace=namespace,
        token_id=token_id,
        token_name=token_name,
    )

    payload = client.resources.get(
        {
            "namespace": namespace,
            "type": "SiderolinkAPIConfigs.omni.sidero.dev",
            "id": "siderolink-config",
        }
    )
    config = _decode_embedded_json(payload)
    spec = _as_spec(config)
    machine_api = _pick_value(spec, "machine_api_advertised_url", "machineApiAdvertisedUrl")
    if not isinstance(machine_api, str) or not machine_api:
        raise typer.BadParameter("siderolink API config does not include machine API endpoint")

    console.print(_join_url(machine_api, resolved))


@jointoken_app.command("delete")
def jointoken_delete(
    ctx: typer.Context,
    token_id: str = typer.Argument(..., help="Join token ID"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.resources.teardown(
        {"namespace": namespace, "type": "JoinTokens.omni.sidero.dev", "id": token_id}
    )
    _print_payload(payload, output=output)


@infraprovider_app.command("create")
def infraprovider_create(
    ctx: typer.Context,
    name: str = typer.Argument(..., help="Infra provider name"),
    armored_pgp_public_key: str | None = typer.Option(
        None,
        "--armored-pgp-public-key",
        help="Optional public key for the infra provider service account",
    ),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.resources.create(
        {
            "resource": {
                "metadata": {
                    "namespace": "infra-provider",
                    "type": "InfraProviders.omni.sidero.dev",
                    "id": name,
                },
                "spec": json.dumps({}),
            }
        }
    )

    if not armored_pgp_public_key:
        _print_payload(payload, output=output)
        return

    service_account_name = f"infra-provider:{name}"
    sa_payload = client.management.create_service_account(
        {
            "name": service_account_name,
            "armored_pgp_public_key": armored_pgp_public_key,
            "role": "InfraProvider",
            "use_user_role": False,
        }
    )

    if output == "table":
        console.print(f"infra provider [bold]{name}[/bold] created")
        console.print(f"service account: [bold]{service_account_name}[/bold]")
        return

    _print_payload(
        {
            "infra_provider": payload,
            "service_account": sa_payload,
            "service_account_name": service_account_name,
        },
        output=output,
    )


@infraprovider_app.command("list")
def infraprovider_list(
    ctx: typer.Context,
    output: str = typer.Option("table", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.resources.list(
        {"namespace": "ephemeral", "type": "InfraProviderCombinedStatuses.omni.sidero.dev"}
    )
    decoded = _decode_list_items(payload)

    if output != "table":
        _print_payload(decoded, output=output)
        return

    table = Table(show_header=True, header_style="bold cyan")
    table.add_column("ID")
    table.add_column("NAME")
    table.add_column("DESCRIPTION")
    table.add_column("CONNECTED")
    table.add_column("ERROR")

    items = decoded.get("items", [])
    if isinstance(items, list):
        for item in items:
            if not isinstance(item, dict):
                continue
            metadata = _as_metadata(item)
            spec = _as_spec(item)
            health = spec.get("health", {})
            if not isinstance(health, dict):
                health = {}

            connected = str(_pick_value(health, "connected") or False).lower()
            error = str(_pick_value(health, "error") or "")
            table.add_row(
                str(metadata.get("id", "")),
                str(_pick_value(spec, "name") or ""),
                str(_pick_value(spec, "description") or ""),
                connected,
                error,
            )

    console.print(table)


@infraprovider_app.command("delete")
def infraprovider_delete(
    ctx: typer.Context,
    name: str = typer.Argument(..., help="Infra provider name"),
    delete_service_account: bool = typer.Option(False, "--delete-service-account"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    payload = client.resources.teardown(
        {
            "namespace": "infra-provider",
            "type": "InfraProviders.omni.sidero.dev",
            "id": name,
        }
    )

    if not delete_service_account:
        _print_payload(payload, output=output)
        return

    sa_name = f"infra-provider:{name}"
    sa_payload = client.management.destroy_service_account({"name": sa_name})
    _print_payload(
        {
            "infra_provider": payload,
            "service_account": sa_payload,
            "service_account_name": sa_name,
        },
        output=output,
    )


@user_app.command("list")
def user_list(
    ctx: typer.Context,
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("table", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    identities_payload = client.resources.list({"namespace": namespace, "type": "Identities.omni.sidero.dev"})
    users_payload = client.resources.list({"namespace": namespace, "type": "Users.omni.sidero.dev"})

    identities = _decode_list_items(identities_payload)
    users = _decode_list_items(users_payload)

    user_roles: dict[str, str] = {}
    user_items = users.get("items", [])
    if isinstance(user_items, list):
        for item in user_items:
            if not isinstance(item, dict):
                continue
            md = _as_metadata(item)
            spec = _as_spec(item)
            user_id = md.get("id")
            if isinstance(user_id, str):
                user_roles[user_id] = str(_pick_value(spec, "role") or "")

    rows: list[dict[str, str]] = []
    identity_items = identities.get("items", [])
    if isinstance(identity_items, list):
        for item in identity_items:
            if not isinstance(item, dict):
                continue
            md = _as_metadata(item)
            spec = _as_spec(item)
            labels = md.get("labels", {})
            if isinstance(labels, dict) and "type-service-account" in labels:
                continue

            user_id = str(_pick_value(spec, "user_id", "userId") or "")
            email = str(md.get("id", ""))
            role = user_roles.get(user_id, "")
            saml_labels: list[str] = []

            if isinstance(labels, dict):
                for key, value in labels.items():
                    if isinstance(key, str) and key.startswith("saml.omni.sidero.dev/"):
                        saml_labels.append(f"{key.removeprefix('saml.omni.sidero.dev/')}={value}")

            saml_labels.sort()
            rows.append(
                {
                    "id": user_id,
                    "email": email,
                    "role": role,
                    "labels": ", ".join(saml_labels),
                }
            )

    if output != "table":
        _print_payload({"items": rows}, output=output)
        return

    table = Table(show_header=True, header_style="bold cyan")
    table.add_column("ID")
    table.add_column("EMAIL")
    table.add_column("ROLE")
    table.add_column("LABELS")
    for row in rows:
        table.add_row(row["id"], row["email"], row["role"], row["labels"])
    console.print(table)


@user_app.command("create")
def user_create(
    ctx: typer.Context,
    email: str = typer.Argument(..., help="User email/identity"),
    role: str = typer.Option(..., "--role", "-r"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    try:
        client.resources.get({"namespace": namespace, "type": "Identities.omni.sidero.dev", "id": email})
    except OmniHTTPError as exc:
        if exc.status_code != 404:
            raise
    else:
        raise typer.BadParameter(f'identity with email "{email}" already exists')

    user_id = str(uuid.uuid4())
    user_payload = client.resources.create(
        {
            "resource": {
                "metadata": {
                    "namespace": namespace,
                    "type": "Users.omni.sidero.dev",
                    "id": user_id,
                },
                "spec": json.dumps({"role": role}),
            }
        }
    )
    identity_payload = client.resources.create(
        {
            "resource": {
                "metadata": {
                    "namespace": namespace,
                    "type": "Identities.omni.sidero.dev",
                    "id": email,
                    "labels": {"user-id": user_id},
                },
                "spec": json.dumps({"user_id": user_id}),
            }
        }
    )

    _print_payload(
        {
            "user_id": user_id,
            "user": user_payload,
            "identity": identity_payload,
        },
        output=output,
    )


@user_app.command("set-role")
def user_set_role(
    ctx: typer.Context,
    email: str = typer.Argument(..., help="User email/identity"),
    role: str = typer.Option(..., "--role", "-r"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    identity_payload = client.resources.get({"namespace": namespace, "type": "Identities.omni.sidero.dev", "id": email})
    identity = _decode_embedded_json(identity_payload)
    identity_spec = _as_spec(identity)
    user_id = _pick_value(identity_spec, "user_id", "userId")
    if not isinstance(user_id, str) or not user_id:
        raise typer.BadParameter(f'identity "{email}" does not include a user ID')

    user_payload = client.resources.get({"namespace": namespace, "type": "Users.omni.sidero.dev", "id": user_id})
    user_resource = _decode_embedded_json(user_payload)
    user_spec = _as_spec(user_resource)
    user_spec["role"] = role

    update_payload = _update_resource_spec(
        client,
        namespace=namespace,
        resource_type="Users.omni.sidero.dev",
        resource_id=user_id,
        spec=user_spec,
    )
    _print_payload(update_payload, output=output)


@user_app.command("delete")
def user_delete(
    ctx: typer.Context,
    emails: list[str] = typer.Argument(..., help="One or more user identities"),
    namespace: str = typer.Option("default", "--namespace", "-n"),
    output: str = typer.Option("json", "--output", "-o"),
) -> None:
    client = _ctx_client(ctx)
    results: list[dict[str, Any]] = []

    for email in emails:
        identity_payload = client.resources.get({"namespace": namespace, "type": "Identities.omni.sidero.dev", "id": email})
        identity = _decode_embedded_json(identity_payload)
        identity_spec = _as_spec(identity)
        user_id = _pick_value(identity_spec, "user_id", "userId")
        if not isinstance(user_id, str) or not user_id:
            raise typer.BadParameter(f'identity "{email}" does not include a user ID')

        identity_delete = client.resources.teardown(
            {"namespace": namespace, "type": "Identities.omni.sidero.dev", "id": email}
        )
        user_delete_payload = client.resources.teardown(
            {"namespace": namespace, "type": "Users.omni.sidero.dev", "id": user_id}
        )
        results.append(
            {
                "email": email,
                "user_id": user_id,
                "identity": identity_delete,
                "user": user_delete_payload,
            }
        )

    _print_payload({"items": results}, output=output)


def run() -> None:
    app()


if __name__ == "__main__":
    run()
