"""The mgf-cloud command-line application — argparse parser + dispatch.

Every resource subcommand programs against the provider-agnostic
:class:`~mgf.cloud.providers.base.CloudProvider`, so the same CLI drives the
``fake`` provider (tests/dry runs) and ``infomaniak`` (real) identically.
Guardrails: ``--dry-run`` on every mutating command, ``--yes`` required to
delete/release, ``--json`` for machine output, and secrets are never printed.

The common flags (``--provider``/``--clouds-yaml``/``--json``/``--dry-run``/
``--yes``) are attached to each leaf subcommand, so they sit *with* the action
(``mgf-cloud vm delete web --yes``).
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path
from typing import TYPE_CHECKING

from mgf.common import bootstrap
from mgf.common.observability import get_logger, report_now

from mgf.cloud import __version__, config
from mgf.cloud.cli._output import emit
from mgf.cloud.exceptions import (
    ConfigError,
    OperationError,
    ProviderError,
    ResourceAlreadyExistsError,
    ResourceNotFoundError,
)
from mgf.cloud.models import SecurityRule, ServerSpec, VolumeSpec, owned_metadata
from mgf.cloud.providers import available_providers, get_provider

if TYPE_CHECKING:
    from collections.abc import Callable

    from mgf.cloud.providers.base import CloudProvider
    from mgf.cloud.remote import RemoteHost

LOG = get_logger(__name__)

_CLOUD_ERRORS = (
    ConfigError,
    ProviderError,
    ResourceNotFoundError,
    ResourceAlreadyExistsError,
    OperationError,
)


# ----------------------------------------------------------------------
# helpers
# ----------------------------------------------------------------------
def _resolve_provider(args: argparse.Namespace) -> CloudProvider:
    if args.provider == "fake":
        return get_provider("fake")
    credentials = config.load(clouds_yaml=args.clouds_yaml, cloud=args.cloud)
    return get_provider(args.provider, credentials)


def _confirm(args: argparse.Namespace, what: str) -> bool:
    if args.yes:
        return True
    print(f"mgf-cloud: refusing to {what} without --yes", file=sys.stderr)
    return False


def _dry(args: argparse.Namespace, what: str) -> bool:
    if args.dry_run:
        print(f"[dry-run] would {what}")
        return True
    return False


# ----------------------------------------------------------------------
# handlers — (provider, args) -> exit code
# ----------------------------------------------------------------------
def _provider_check(provider: CloudProvider, args: argparse.Namespace) -> int:
    flavors = provider.list_flavors()
    emit({"provider": provider.name, "ok": True, "flavors": len(flavors)}, as_json=args.as_json)
    return 0


def _vm_list(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.list_servers(owner=args.owner), as_json=args.as_json)
    return 0


def _vm_show(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.get_server(args.name), as_json=args.as_json)
    return 0


def _vm_create(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"create vm {args.name} ({args.flavor}/{args.image})"):
        return 0
    user_data = Path(args.user_data).read_text(encoding="utf-8") if args.user_data else None
    spec = ServerSpec(
        name=args.name,
        flavor=args.flavor,
        image=args.image,
        network=args.network,
        key_name=args.key_name,
        security_groups=tuple(args.secgroup or ()),
        user_data=user_data,
        volume_size_gb=args.volume_size,
        assign_floating_ip=args.floating_ip,
        metadata=owned_metadata(args.owner),
    )
    emit(provider.create_server(spec), as_json=args.as_json)
    return 0


def _vm_delete(provider: CloudProvider, args: argparse.Namespace) -> int:
    if not _confirm(args, f"delete vm {args.name}"):
        return 2
    if _dry(args, f"delete vm {args.name}"):
        return 0
    provider.delete_server(args.name)
    return 0


def _vm_reboot(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"reboot vm {args.name}"):
        return 0
    provider.reboot_server(args.name, hard=args.hard)
    return 0


def _vm_resize(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"resize vm {args.name} -> {args.flavor}"):
        return 0
    provider.resize_server(args.name, args.flavor)
    return 0


def _vm_snapshot(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"snapshot vm {args.name} -> {args.snapshot_name}"):
        return 0
    emit(provider.snapshot_server(args.name, args.snapshot_name), as_json=args.as_json)
    return 0


def _flavor_list(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.list_flavors(), as_json=args.as_json)
    return 0


def _fmt_money(value: float, currency: str) -> str:
    """Money to a readable precision — 4 dp for sub-euro rates, else 2 dp."""
    symbol = {"EUR": "€", "USD": "$", "GBP": "£"}.get(currency, currency + " ")
    return f"{symbol}{value:.4f}" if abs(value) < 1 else f"{symbol}{value:,.2f}"


def _cost_cmd(provider: CloudProvider, args: argparse.Namespace) -> int:
    """Estimate the current cloud-usage cost: run-rate + accrued, with a breakdown."""
    from mgf.cloud import cost as costmod

    servers = provider.list_servers(owner=args.owner)
    flavors = provider.list_flavors()
    volumes = floating_ips = None
    if not args.compute_only:
        try:
            volumes = provider.list_volumes()
        except _CLOUD_ERRORS:
            volumes = None
        try:
            floating_ips = provider.list_floating_ips()
        except _CLOUD_ERRORS:
            floating_ips = None
    report = costmod.estimate(
        servers=list(servers),
        flavors=list(flavors),
        volumes=list(volumes) if volumes is not None else None,
        floating_ips=list(floating_ips) if floating_ips is not None else None,
        rates=costmod.rates_from_config(args.rates),
    )

    if args.as_json:
        emit(
            {
                "currency": report.currency,
                "generated_at": report.generated_at.isoformat(),
                "rate_hourly": round(report.hourly, 6),
                "rate_daily": round(report.daily, 4),
                "rate_monthly": round(report.monthly, 2),
                "accrued": round(report.accrued, 4),
                "estimated_rates": True,
                "lines": [
                    {
                        "kind": ln.kind,
                        "name": ln.name,
                        "detail": ln.detail,
                        "hourly": round(ln.hourly, 6),
                        "exact": ln.exact,
                        "age_hours": round(ln.age_hours, 2) if ln.age_hours is not None else None,
                        "accrued": round(ln.accrued, 4),
                    }
                    for ln in report.lines
                ],
            },
            as_json=True,
        )
        return 0

    cur = report.currency
    print(f"mgf-cloud cost — {provider.name} (estimate)")
    print(f"  as of {report.generated_at:%Y-%m-%d %H:%M UTC}\n")
    if not report.lines:
        print("  (no billable resources)")
        return 0
    print(f"  {'RESOURCE':<24}{'TYPE':<13}{'SPEC':<22}{'RATE/h':>11}{'AGE':>8}{'ACCRUED':>12}")
    for ln in report.lines:
        age = f"{ln.age_hours:.1f}h" if ln.age_hours is not None else "—"
        # Flag only server rates estimated from specs (the price-table miss); a
        # volume's per-GB rate is always a rate, not a table lookup.
        star = "*" if (ln.kind == "server" and not ln.exact) else ""
        print(
            f"  {ln.name[:23]:<24}{ln.kind:<13}{ln.detail[:21]:<22}"
            f"{_fmt_money(ln.hourly, cur) + star:>11}{age:>8}{_fmt_money(ln.accrued, cur):>12}"
        )
    print("  " + "─" * 88)
    print(f"  {'TOTAL':<24}{'':<13}{'':<22}{_fmt_money(report.hourly, cur):>11}")
    print(
        f"    ≈ {_fmt_money(report.daily, cur)}/day"
        f"   {_fmt_money(report.monthly, cur)}/month   "
        f"accrued so far {_fmt_money(report.accrued, cur)}"
    )
    notes = []
    if report.any_estimated:
        notes.append("* rate estimated from flavor specs (not in the price table)")
    notes.append(
        "rates are best-effort Infomaniak public-cloud prices (June 2026), "
        "configurable — verify against your invoice"
    )
    print()
    for note in notes:
        print(f"  {note}")
    return 0


def _image_list(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.list_images(), as_json=args.as_json)
    return 0


def _volume_list(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.list_volumes(), as_json=args.as_json)
    return 0


def _volume_create(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"create volume {args.name} ({args.size} GB)"):
        return 0
    spec = VolumeSpec(name=args.name, size_gb=args.size, bootable=args.bootable)
    emit(provider.create_volume(spec), as_json=args.as_json)
    return 0


def _volume_attach(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"attach volume {args.volume} -> {args.server}"):
        return 0
    provider.attach_volume(args.volume, args.server)
    return 0


def _volume_detach(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"detach volume {args.volume} from {args.server}"):
        return 0
    provider.detach_volume(args.volume, args.server)
    return 0


def _volume_delete(provider: CloudProvider, args: argparse.Namespace) -> int:
    if not _confirm(args, f"delete volume {args.volume}"):
        return 2
    if _dry(args, f"delete volume {args.volume}"):
        return 0
    provider.delete_volume(args.volume)
    return 0


def _volume_snapshot(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"snapshot volume {args.volume} -> {args.snapshot_name}"):
        return 0
    emit(provider.snapshot_volume(args.volume, args.snapshot_name), as_json=args.as_json)
    return 0


def _fip_list(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.list_floating_ips(), as_json=args.as_json)
    return 0


def _fip_create(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, "allocate a floating IP"):
        return 0
    emit(provider.create_floating_ip(args.network), as_json=args.as_json)
    return 0


def _fip_attach(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"attach floating IP {args.address} -> {args.server}"):
        return 0
    provider.attach_floating_ip(args.address, args.server)
    return 0


def _fip_detach(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"detach floating IP {args.address}"):
        return 0
    provider.detach_floating_ip(args.address)
    return 0


def _fip_release(provider: CloudProvider, args: argparse.Namespace) -> int:
    if not _confirm(args, f"release floating IP {args.address}"):
        return 2
    if _dry(args, f"release floating IP {args.address}"):
        return 0
    provider.release_floating_ip(args.address)
    return 0


def _secgroup_list(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.list_security_groups(), as_json=args.as_json)
    return 0


def _parse_rule(spec: str) -> SecurityRule:
    """Parse ``proto:port[:cidr]`` (e.g. ``tcp:22`` / ``tcp:80:0.0.0.0/0``)."""
    parts = spec.split(":")
    proto = parts[0] or None
    port = int(parts[1]) if len(parts) > 1 and parts[1] else None
    cidr = parts[2] if len(parts) > 2 and parts[2] else "0.0.0.0/0"
    return SecurityRule(protocol=proto, port_min=port, port_max=port, remote_ip_prefix=cidr)


def _secgroup_create(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"create security group {args.name}"):
        return 0
    rules = tuple(_parse_rule(spec) for spec in (args.rule or ()))
    emit(provider.create_security_group(args.name, args.description, rules), as_json=args.as_json)
    return 0


def _secgroup_delete(provider: CloudProvider, args: argparse.Namespace) -> int:
    if not _confirm(args, f"delete security group {args.name}"):
        return 2
    if _dry(args, f"delete security group {args.name}"):
        return 0
    provider.delete_security_group(args.name)
    return 0


def _keypair_list(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.list_keypairs(), as_json=args.as_json)
    return 0


def _keypair_create(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"create keypair {args.name}"):
        return 0
    public_key = Path(args.public_key_file).read_text(encoding="utf-8").strip()
    emit(provider.create_keypair(args.name, public_key), as_json=args.as_json)
    return 0


def _keypair_delete(provider: CloudProvider, args: argparse.Namespace) -> int:
    if not _confirm(args, f"delete keypair {args.name}"):
        return 2
    if _dry(args, f"delete keypair {args.name}"):
        return 0
    provider.delete_keypair(args.name)
    return 0


def _obj_containers(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.list_containers(), as_json=args.as_json)
    return 0


def _obj_mkcontainer(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"create container {args.container}"):
        return 0
    emit(provider.create_container(args.container), as_json=args.as_json)
    return 0


def _obj_rmcontainer(provider: CloudProvider, args: argparse.Namespace) -> int:
    if not _confirm(args, f"delete container {args.container}"):
        return 2
    if _dry(args, f"delete container {args.container}"):
        return 0
    provider.delete_container(args.container)
    return 0


def _obj_ls(provider: CloudProvider, args: argparse.Namespace) -> int:
    emit(provider.list_objects(args.container), as_json=args.as_json)
    return 0


def _obj_put(provider: CloudProvider, args: argparse.Namespace) -> int:
    if _dry(args, f"put {args.name} into {args.container}"):
        return 0
    data = Path(args.file).read_bytes()
    emit(provider.put_object(args.container, args.name, data), as_json=args.as_json)
    return 0


def _obj_get(provider: CloudProvider, args: argparse.Namespace) -> int:
    data = provider.get_object(args.container, args.name)
    if args.file:
        Path(args.file).write_bytes(data)
        emit(
            {
                "container": args.container,
                "object": args.name,
                "bytes": len(data),
                "written": args.file,
            },
            as_json=args.as_json,
        )
    else:
        sys.stdout.buffer.write(data)
    return 0


def _obj_rm(provider: CloudProvider, args: argparse.Namespace) -> int:
    if not _confirm(args, f"delete object {args.name} from {args.container}"):
        return 2
    if _dry(args, f"delete object {args.name} from {args.container}"):
        return 0
    provider.delete_object(args.container, args.name)
    return 0


# --- remote / bootstrap (host-based, no provider) ------------------------
def _remote_host(args: argparse.Namespace) -> RemoteHost:
    from mgf.cloud.remote import RemoteHost

    return RemoteHost(host=args.host, user=args.user, port=args.port, key_filename=args.key)


def _remote_exec(args: argparse.Namespace) -> int:
    from mgf.cloud import remote

    client = remote.connect(_remote_host(args), accept_new=args.accept_new)
    try:
        command = f"sudo {args.cmd}" if args.sudo else args.cmd
        result = client.exec(command)
    finally:
        client.close()
    emit(result, as_json=args.as_json)
    return 0 if result.ok else 1


def _remote_push(args: argparse.Namespace) -> int:
    from mgf.cloud import remote

    data = Path(args.local).read_bytes()
    client = remote.connect(_remote_host(args), accept_new=args.accept_new)
    try:
        client.push_bytes(data, args.remote)
    finally:
        client.close()
    emit({"pushed": args.local, "to": args.remote, "bytes": len(data)}, as_json=args.as_json)
    return 0


def _bootstrap_cmd(args: argparse.Namespace) -> int:
    from mgf.cloud import remote

    summary = f"bootstrap {args.host}"
    if args.docker:
        summary += " +docker"
    if args.compose_dir:
        summary += f" +compose:{args.compose_dir}"
    if _dry(args, summary):
        return 0
    client = remote.connect(_remote_host(args), accept_new=args.accept_new)
    results: list[object] = []
    try:
        if args.docker:
            results += remote.install_docker(client)
        if args.compose_dir:
            files = {
                path.name: path.read_bytes()
                for path in sorted(Path(args.compose_dir).iterdir())
                if path.is_file()
            }
            results += remote.deploy_compose_bundle(client, files, args.remote_dir)
    finally:
        client.close()
    emit(results, as_json=args.as_json)
    return 0


_HANDLERS: dict[tuple[str, str], Callable[[CloudProvider, argparse.Namespace], int]] = {
    ("provider", "check"): _provider_check,
    ("vm", "list"): _vm_list,
    ("vm", "show"): _vm_show,
    ("vm", "create"): _vm_create,
    ("vm", "delete"): _vm_delete,
    ("vm", "reboot"): _vm_reboot,
    ("vm", "resize"): _vm_resize,
    ("vm", "snapshot"): _vm_snapshot,
    ("flavor", "list"): _flavor_list,
    ("image", "list"): _image_list,
    ("volume", "list"): _volume_list,
    ("volume", "create"): _volume_create,
    ("volume", "attach"): _volume_attach,
    ("volume", "detach"): _volume_detach,
    ("volume", "delete"): _volume_delete,
    ("volume", "snapshot"): _volume_snapshot,
    ("floating-ip", "list"): _fip_list,
    ("floating-ip", "create"): _fip_create,
    ("floating-ip", "attach"): _fip_attach,
    ("floating-ip", "detach"): _fip_detach,
    ("floating-ip", "release"): _fip_release,
    ("secgroup", "list"): _secgroup_list,
    ("secgroup", "create"): _secgroup_create,
    ("secgroup", "delete"): _secgroup_delete,
    ("keypair", "list"): _keypair_list,
    ("keypair", "create"): _keypair_create,
    ("keypair", "delete"): _keypair_delete,
    ("object", "containers"): _obj_containers,
    ("object", "mkcontainer"): _obj_mkcontainer,
    ("object", "rmcontainer"): _obj_rmcontainer,
    ("object", "ls"): _obj_ls,
    ("object", "put"): _obj_put,
    ("object", "get"): _obj_get,
    ("object", "rm"): _obj_rm,
}


# ----------------------------------------------------------------------
# parser
# ----------------------------------------------------------------------
def _common() -> argparse.ArgumentParser:
    """The flags every leaf subcommand shares (so they sit with the action)."""
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument(
        "--provider",
        default="infomaniak",
        help="cloud provider (default: infomaniak; 'fake' for a local dry test)",
    )
    common.add_argument("--clouds-yaml", default=None, help="path to an OpenStack clouds.yaml")
    common.add_argument(
        "--cloud",
        default="openstack",
        help=(
            "clouds.yaml entry to use (default: openstack). Infomaniak's downloaded "
            "file names its entry PCP-<id>-<region>, e.g. PCP-HOESFJV-dc3-a"
        ),
    )
    common.add_argument("--json", dest="as_json", action="store_true", help="emit JSON")
    common.add_argument("--dry-run", action="store_true", help="plan mutating ops; don't apply")
    common.add_argument("--yes", action="store_true", help="confirm destructive ops")
    return common


def build_parser() -> argparse.ArgumentParser:
    """Build the ``mgf-cloud`` argument parser."""
    common = _common()
    parser = argparse.ArgumentParser(
        prog="mgf-cloud",
        description="Magogi Foundation cloud-resource interface (Infomaniak first).",
    )
    parser.add_argument("--version", action="version", version=f"mgf-cloud {__version__}")
    sub = parser.add_subparsers(dest="command", required=True)

    sub.add_parser(
        "version",
        parents=[common],
        help="print the version + build/runtime provenance (--json for the facts)",
    )

    # Operability verbs (OP-02/05/07) — app-own diagnostics, no live cloud call.
    sub.add_parser(
        "doctor",
        parents=[common],
        help="check install + config health offline (exit = failed-check count)",
    ).add_argument("--rates", default=None, help="rates.toml override to parse-check")
    sub.add_parser(
        "explain",
        parents=[common],
        help="show the resolved CloudConfig + which source won (secrets redacted)",
    )
    bundle = sub.add_parser(
        "bundle", parents=[common], help="write a single redacted diagnostic tarball"
    )
    bundle.add_argument(
        "--output",
        "-o",
        default=None,
        help="tarball path (default: ./mgf-cloud-bundle-<ts>.tar.gz)",
    )
    bundle.add_argument("--rates", default=None, help=argparse.SUPPRESS)

    prov = sub.add_parser("provider", help="provider info").add_subparsers(
        dest="action", required=True
    )
    prov.add_parser("list", parents=[common], help="list available providers")
    prov.add_parser(
        "check", parents=[common], help="verify the selected provider's credentials work"
    )

    vm = sub.add_parser("vm", help="virtual machines").add_subparsers(dest="action", required=True)
    vm.add_parser("list", parents=[common], help="list VMs").add_argument("--owner", default=None)
    vm.add_parser("show", parents=[common], help="show a VM").add_argument("name")
    create = vm.add_parser("create", parents=[common], help="create a VM")
    create.add_argument("name")
    create.add_argument("--flavor", required=True)
    create.add_argument("--image", required=True)
    create.add_argument("--network", default=None)
    create.add_argument("--key-name", default=None)
    create.add_argument("--secgroup", action="append", help="repeatable")
    create.add_argument("--user-data", default=None, help="cloud-init file")
    create.add_argument("--volume-size", type=int, default=None, help="boot volume GB")
    create.add_argument("--floating-ip", action="store_true")
    create.add_argument("--owner", default="federation")
    vm.add_parser("delete", parents=[common], help="delete a VM").add_argument("name")
    reboot = vm.add_parser("reboot", parents=[common], help="reboot a VM")
    reboot.add_argument("name")
    reboot.add_argument("--hard", action="store_true")
    resize = vm.add_parser("resize", parents=[common], help="resize a VM")
    resize.add_argument("name")
    resize.add_argument("--flavor", required=True)
    snap = vm.add_parser("snapshot", parents=[common], help="snapshot a VM")
    snap.add_argument("name")
    snap.add_argument("snapshot_name")

    sub.add_parser("flavor", help="flavours").add_subparsers(
        dest="action", required=True
    ).add_parser("list", parents=[common], help="list flavours")
    sub.add_parser("image", help="images").add_subparsers(dest="action", required=True).add_parser(
        "list", parents=[common], help="list images"
    )

    cost_p = sub.add_parser(
        "cost", parents=[common], help="estimate current cloud-usage cost (rate + accrued)"
    )
    cost_p.add_argument("--owner", default=None, help="only count resources with this owner tag")
    cost_p.add_argument(
        "--compute-only", action="store_true", help="servers only; skip volumes + floating IPs"
    )
    cost_p.add_argument(
        "--rates",
        default=None,
        help="path to a rates.toml override (else $MGF_CLOUD_RATES, "
        "~/.config/mgf-cloud/rates.toml, then built-in Infomaniak defaults)",
    )

    vol = sub.add_parser("volume", help="block storage").add_subparsers(
        dest="action", required=True
    )
    vol.add_parser("list", parents=[common], help="list volumes")
    vcreate = vol.add_parser("create", parents=[common], help="create a volume")
    vcreate.add_argument("name")
    vcreate.add_argument("--size", type=int, required=True, help="GB")
    vcreate.add_argument("--bootable", action="store_true")
    vattach = vol.add_parser("attach", parents=[common], help="attach a volume")
    vattach.add_argument("volume")
    vattach.add_argument("--server", required=True)
    vdetach = vol.add_parser("detach", parents=[common], help="detach a volume")
    vdetach.add_argument("volume")
    vdetach.add_argument("--server", required=True)
    vol.add_parser("delete", parents=[common], help="delete a volume").add_argument("volume")
    vsnap = vol.add_parser("snapshot", parents=[common], help="snapshot a volume")
    vsnap.add_argument("volume")
    vsnap.add_argument("snapshot_name")

    fip = sub.add_parser("floating-ip", help="floating IPs").add_subparsers(
        dest="action", required=True
    )
    fip.add_parser("list", parents=[common], help="list floating IPs")
    fip.add_parser("create", parents=[common], help="allocate a floating IP").add_argument(
        "--network", default=None
    )
    fattach = fip.add_parser("attach", parents=[common], help="attach a floating IP")
    fattach.add_argument("address")
    fattach.add_argument("--server", required=True)
    fip.add_parser("detach", parents=[common], help="detach a floating IP").add_argument("address")
    fip.add_parser("release", parents=[common], help="release a floating IP").add_argument(
        "address"
    )

    sg = sub.add_parser("secgroup", help="security groups").add_subparsers(
        dest="action", required=True
    )
    sg.add_parser("list", parents=[common], help="list security groups")
    sgcreate = sg.add_parser("create", parents=[common], help="create a security group")
    sgcreate.add_argument("name")
    sgcreate.add_argument("--description", default="")
    sgcreate.add_argument("--rule", action="append", help="proto:port[:cidr], repeatable")
    sg.add_parser("delete", parents=[common], help="delete a security group").add_argument("name")

    kp = sub.add_parser("keypair", help="SSH keypairs").add_subparsers(dest="action", required=True)
    kp.add_parser("list", parents=[common], help="list keypairs")
    kpcreate = kp.add_parser("create", parents=[common], help="register a public key")
    kpcreate.add_argument("name")
    kpcreate.add_argument("--public-key-file", required=True)
    kp.add_parser("delete", parents=[common], help="delete a keypair").add_argument("name")

    obj = sub.add_parser("object", help="object storage").add_subparsers(
        dest="action", required=True
    )
    obj.add_parser("containers", parents=[common], help="list containers")
    obj.add_parser("mkcontainer", parents=[common], help="create a container").add_argument(
        "container"
    )
    obj.add_parser("rmcontainer", parents=[common], help="delete a container").add_argument(
        "container"
    )
    obj.add_parser("ls", parents=[common], help="list objects").add_argument("container")
    oput = obj.add_parser("put", parents=[common], help="upload an object")
    oput.add_argument("container")
    oput.add_argument("name")
    oput.add_argument("--file", required=True)
    oget = obj.add_parser("get", parents=[common], help="download an object")
    oget.add_argument("container")
    oget.add_argument("name")
    oget.add_argument("--file", default=None, help="write here (else stdout)")
    orm = obj.add_parser("rm", parents=[common], help="delete an object")
    orm.add_argument("container")
    orm.add_argument("name")

    web = sub.add_parser("web", parents=[common], help="launch the local web GUI")
    web.add_argument("--host", default="127.0.0.1", help="bind address (default: localhost)")
    web.add_argument("--port", type=int, default=8787)

    ssh = argparse.ArgumentParser(add_help=False)
    ssh.add_argument("--user", default="ubuntu", help="SSH user (default: ubuntu)")
    ssh.add_argument("--port", type=int, default=22, help="SSH port")
    ssh.add_argument("--key", default=None, help="SSH private key file")
    ssh.add_argument("--accept-new", action="store_true", help="auto-accept an unknown host key")

    rem = sub.add_parser("remote", help="run commands / push files over SSH").add_subparsers(
        dest="action", required=True
    )
    rexec = rem.add_parser("exec", parents=[common, ssh], help="run a command over SSH")
    rexec.add_argument("host")
    rexec.add_argument("cmd", help="the command to run")
    rexec.add_argument("--sudo", action="store_true")
    rpush = rem.add_parser("push", parents=[common, ssh], help="push a file over SSH")
    rpush.add_argument("host")
    rpush.add_argument("local")
    rpush.add_argument("remote")

    boot = sub.add_parser(
        "bootstrap", parents=[common, ssh], help="bootstrap a host (Docker + a compose bundle)"
    )
    boot.add_argument("host")
    boot.add_argument("--docker", action="store_true", help="install Docker")
    boot.add_argument(
        "--compose-dir", default=None, help="local docker-compose bundle dir to deploy"
    )
    boot.add_argument("--remote-dir", default="/opt/mgf", help="remote deploy dir")

    return parser


def _dispatch(args: argparse.Namespace) -> int:
    if args.command == "version":
        from mgf.cloud.cli._version import run_version

        return run_version(args)
    if args.command == "doctor":
        from mgf.cloud.cli._doctor import run_doctor

        return run_doctor(args)
    if args.command == "explain":
        from mgf.cloud.cli._explain import run_explain

        return run_explain(args)
    if args.command == "bundle":
        from mgf.cloud.cli._bundle import run_bundle

        return run_bundle(args)
    if args.command == "provider" and args.action == "list":
        emit(list(available_providers()), as_json=args.as_json)
        return 0
    if args.command == "web":
        provider = _resolve_provider(args)
        try:
            from mgf.cloud.web import serve
        except ImportError as exc:
            raise ConfigError(
                "the web GUI needs the web extra: pip install 'mgf-cloud[web]'"
            ) from exc
        serve(provider, args.host, args.port)
        return 0
    if args.command == "remote":
        return _remote_exec(args) if args.action == "exec" else _remote_push(args)
    if args.command == "bootstrap":
        return _bootstrap_cmd(args)
    if args.command == "cost":
        return _cost_cmd(_resolve_provider(args), args)
    provider = _resolve_provider(args)
    handler = _HANDLERS.get((args.command, args.action))
    if handler is None:  # pragma: no cover - argparse 'required' prevents this
        print(f"mgf-cloud: unknown command {args.command} {args.action}", file=sys.stderr)
        return 2
    return handler(provider, args)


def main(argv: list[str] | None = None) -> int:
    """Run the ``mgf-cloud`` CLI; return the process exit code.

    OB-01/LG-02/LG-06: ``mgf.common.bootstrap`` wires the federation logging
    stack exactly once per invocation (console — text on a TTY, JSON off it —
    plus a rotating JSON file sink), so this credential-holding tool emits a
    searchable, redacted stream. EH-04: an untyped bug is routed to a crash
    report + an actionable message instead of a raw traceback.
    """
    args = build_parser().parse_args(argv)
    with bootstrap(app_name="mgf-cloud", app_version=__version__):
        try:
            return _dispatch(args)
        except _CLOUD_ERRORS as exc:
            LOG.warning("mgf-cloud command failed: %s", exc)
            print(f"mgf-cloud: {exc}", file=sys.stderr)
            return 1
        except Exception as exc:  # EH-04: untyped bug → crash report + clean exit
            report_now(exc, source="mgf.cloud.cli")
            print(f"mgf-cloud: unexpected error: {exc}", file=sys.stderr)
            return 1
