"""Basic infrastructure CLI."""

import json
import os
import re
import signal
import sys
import webbrowser
from collections.abc import Collection
from contextlib import suppress
from datetime import datetime, timedelta, timezone
from enum import Enum
from functools import cache
from pathlib import Path
from subprocess import PIPE, STDOUT, Popen, TimeoutExpired
from time import sleep
from typing import Annotated, Any

import humanize
import pyjson5
import typer
from msgspec import Meta, Struct, ValidationError, field
from msgspec.json import decode as json_decode
from msgspec.json import encode as json_encode
from msgspec.structs import asdict
from msgspec.yaml import encode as yaml_encode
from pyjson5 import Json5DecoderException
from pylev import levenshtein
from pytz import UTC
from rich.panel import Panel
from rich.prompt import Prompt
from rich.table import Table
from slices_clientlib_bi import SlicesClient
from slices_clientlib_bi.bi_machine import Machine
from slices_clientlib_bi.bi_resource import (
    BareMetalLinkCreate,
    ComputeResource,
    ComputeResourceCreate,
    ConsoleLoginDetails,
    FailureCode,
    NetworkInterfaceCreate,
    NewResourcesRequest,
    ResourceState,
    SSHInfo,
    Token,
)
from slices_clientlib_core.base.exceptions import (
    ApiException,
    ApiResponseError,
    ForbiddenException,
    NotFoundException,
)
from slices_clientlib_core.experiment import Experiment
from typer._click.types import ParamType

from slices_cli_bi import console, err_console
from slices_cli_bi.core.config import get_bi_settings
from slices_cli_bi.core.context import Context
from slices_cli_bi.core.error_details import ErrorDetailsHandler
from slices_cli_bi.core.progress import follow_progress
from slices_cli_bi.util.errors import get_api_error_detail
from slices_cli_bi.util.pubkey import sha256_fingerprint
from slices_cli_bi.util.pubkey_extra import create_add_ssh_pubkey_command
from slices_cli_bi.util.ssh_known_hosts import SSHKnownHostsManager
from slices_cli_bi.util.time import duration_or_datetime, friendly_datetime, local_timezone
from slices_cli_bi.util.type_id import get_type_id_domain, is_type_id
from slices_cli_bi.util.typer import AliasGroup

from .bi_context import BIContext, BIContextObj
from .bi_diskimage import disk_images_app
from .bi_flavor import flavors_app
from .bi_helpers import (
    bi_get_best_ssh_login,
    bi_get_experiment,
    bi_get_resource,
    handle_some_exceptions,
)
from .bi_infra import infras_app
from .bi_known_hosts import known_hosts_app
from .bi_machine import machines_app
from .bi_snapshot import snapshots_app

bi_app = typer.Typer(short_help="Basic Infrastructure", no_args_is_help=True, cls=AliasGroup)
AliasGroup.register_hidden_alias("flavor", "flavors")
AliasGroup.register_hidden_alias("diskimage", "diskimages")
AliasGroup.register_hidden_alias("image", "images")
AliasGroup.register_hidden_alias("snapshot", "snapshots")
AliasGroup.register_hidden_alias("infrastructure", "infrastructures")
AliasGroup.register_hidden_alias("infra", "infras")
AliasGroup.register_hidden_alias("machine", "machines")
bi_app.add_typer(flavors_app, name="flavor", short_help="Flavors.", rich_help_panel="Commands with subcommands")
bi_app.add_typer(
    disk_images_app,
    name="diskimage | image",
    short_help="Disk images.",
    rich_help_panel="Commands with subcommands",
)
bi_app.add_typer(
    snapshots_app,
    name="snapshot",
    short_help="Resource Snapshots.",
    rich_help_panel="Commands with subcommands",
)
bi_app.add_typer(
    infras_app,
    name="infra | infrastructure",
    short_help="Slices Infrastructures.",
    rich_help_panel="Commands with subcommands",
)
bi_app.add_typer(
    machines_app,
    name="machine",
    short_help="Slices Machines.",
    rich_help_panel="Commands with subcommands",
)


def _get_requested_bi_resources(
    ctx: BIContext,
    exp: Experiment,
    resources: list[str] | None,
    include_expired: bool,
    include_terminated: bool,
) -> Collection[ComputeResource]:
    if not resources:  # all resources
        return (
            ctx.obj.api.for_experiment(exp.id, exp.project_id)
            .bi(ctx.obj.infra_id)
            .resources.list(
                include_expired=include_expired,
                include_terminated=include_terminated,
            )
        )

    return [_get_resource(ctx, exp, resource_id) for resource_id in resources]


bi_app.add_typer(
    known_hosts_app,
    name="known-hosts",
    short_help="Edit ~/.ssh/known_hosts",
    rich_help_panel="Commands with subcommands",
)


def _validate_duration_option(
    ctx: typer.Context,
    _param: typer.CallbackParam,
    value: datetime,
) -> datetime:
    """Validate a textual timedelta (used for typer arguments)."""
    if ctx.resilient_parsing:
        return value

    if value < datetime.now(UTC).replace(second=0, microsecond=0) + timedelta(hours=1):
        raise typer.BadParameter(f"{value!r} is less than the minimum allowed value of '1 hour'")
    # if value > datetime.now(UTC).replace(second=0, microsecond=0) + timedelta(days=90):
    #     raise typer.BadParameter(f"{value!r} is more than the maximum allowed value of '90 days'")

    return value


def bi_infra_callback(ctx: Context, _param: typer.CallbackParam, value: str):
    """Validate bi_infra value."""
    if ctx.resilient_parsing:
        return "bi-orchestrator"

    if value != "bi-orchestrator" and value not in ctx.obj.api.bi_config.bi_infrastructures:
        raise typer.BadParameter("Invalid BI Infrastructure ID")

    return value


def complete_infra_id(incomplete_value: str):
    """Shell completion for infra ID parameter."""
    # Note: this ignores custom config (but we don't have ctx.obj here)
    slices_client = SlicesClient(central_config=None, bi_config=None)
    infras_ids = [k for k, v in slices_client._bi_api_clients_config.bi_infrastructures.items() if "dev" not in v.tags]
    for infras_id in infras_ids:
        if infras_id.startswith(incomplete_value):
            yield infras_id


@bi_app.callback(invoke_without_command=False)
def initialize_bi(
    ctx: Context,
    infra: Annotated[
        str,
        typer.Option(
            "--infra",
            "--infra-id",
            # --site-id is supported for backward compatibility
            "--site-id",
            # SLICES_BI_SITE_ID is supported for backward compatibility
            envvar=["SLICES_BI_INFRA", "SLICES_BI_INFRA_ID", "SLICES_BI_SITE_ID"],
            callback=bi_infra_callback,
            help="Slices Infrastructure ID. "
            "(Do not use --site-id or $SLICES_BI_SITE_ID, they are deprecated and will be removed.)",
            autocompletion=complete_infra_id,
        ),
    ] = "bi-orchestrator",
    #  cluster_id: Annotated[str, typer.Option(envvar="SLICES_BI_CLUSTER_ID")] = "default",
):
    """Initialize BI Context."""
    if ctx.obj.user_info is None:
        err_console.print("[red]You must be logged in. :x:")
        raise typer.Exit(code=1)

    ctx.obj = BIContextObj(
        api=ctx.obj.api,
        user_info=ctx.obj.user_info,
        other_users_info=ctx.obj.other_users_info,
        infra_id=infra,
        cluster_id="default",  # cluster_id,
    )


def _get_registered_pubkeys(ctx: BIContext, experiment: Experiment) -> list[str]:
    """Get public keys of all members in the experiment."""

    return [
        f"{pubkey.public_key} {ctx.obj.other_users_info.try_get_user_full_name(member.user_id)}"
        for member in ctx.obj.api.experiment.member(experiment.id).list()
        for pubkey in ctx.obj.api.public_key.list_for_user(member.user_id)
    ]


MAX_FLAVOR_STRING_DISTANCE = 5


@cache
def _find_flavor_id(
    ctx: BIContext,
    flavor_any_id: str,
    infra_id: str | None = None,
) -> str:
    if flavor_any_id == "default":
        return "default"

    closest_match: str | None = None
    closest_match_distance = -1

    flavors_api = ctx.obj.api.for_project(ctx.obj.require_project_id()).bi(infra_id or ctx.obj.infra_id).flavor

    for flavor in flavors_api.list():
        ids_to_match = [flavor.id, flavor.friendly_name]

        if flavor_any_id.lower() in [id_to_match.lower() for id_to_match in ids_to_match]:
            return flavor.id

        for id_to_match in ids_to_match:
            distance = levenshtein(id_to_match.lower(), flavor_any_id.lower())

            if not closest_match or distance < closest_match_distance:
                closest_match = id_to_match
                closest_match_distance = distance

    if closest_match_distance <= MAX_DISK_IMAGE_STRING_DISTANCE:
        err_console.print(f"[red]Unknown flavor {flavor_any_id!r} :mag:")
        err_console.print(f"Did you mean: '{closest_match}' ?")
        raise typer.Exit(code=2)

    err_console.print(f"[red]Unknown flavor {flavor_any_id!r} :mag:")
    err_console.print("Check: [blue]slices bi flavor list")
    raise typer.Exit(code=1)


MAX_DISK_IMAGE_STRING_DISTANCE = 5


@cache
def _find_disk_image_id(
    ctx: BIContext,
    disk_image_any_id: str,
    infra_id: str | None = None,
) -> str:
    if disk_image_any_id == "default":
        return disk_image_any_id

    # There are also hidden images, which can be used by specifying their TypeID.
    # So in case we receive an image type ID, we just assume it is a valid image ID.
    if is_type_id(disk_image_any_id, type="image"):
        return disk_image_any_id

    closest_match: str | None = None
    closest_match_distance = -1

    disk_image_api = ctx.obj.api.for_project(ctx.obj.require_project_id()).bi(infra_id or ctx.obj.infra_id).image

    for disk_image in disk_image_api.list():
        ids_to_match = [
            disk_image.id,
            disk_image.friendly_name,
            *[tag[len("name=") :] for tag in (disk_image.tags or []) if tag.startswith("name=")],
        ]

        if disk_image_any_id.lower() in [id_to_match.lower() for id_to_match in ids_to_match]:
            return disk_image.id

        for id_to_match in ids_to_match:
            distance = levenshtein(id_to_match.lower(), disk_image_any_id.lower())

            if not closest_match or distance < closest_match_distance:
                closest_match = id_to_match
                closest_match_distance = distance

    if closest_match_distance <= MAX_DISK_IMAGE_STRING_DISTANCE:
        err_console.print(f"[red]Unknown disk image {disk_image_any_id!r} :mag:")
        err_console.print(f"Did you mean: '{closest_match}' ?")
        raise typer.Exit(code=2)

    err_console.print(f"[red]Unknown disk image {disk_image_any_id!r} :mag:")
    err_console.print("Check: [blue]slices bi diskimage list")
    raise typer.Exit(code=1)


# cfr. https://docs.cloud-init.io/en/24.1/explanation/format.html#headers-and-content-types
CLOUD_INIT_PREFIXES = (
    "#cloud-config",  # https://docs.cloud-init.io/en/24.1/explanation/format.html#cloud-config-data
    "#!",  # https://docs.cloud-init.io/en/24.1/explanation/format.html#user-data-script
    "#cloud-boothook",  # https://docs.cloud-init.io/en/24.1/explanation/format.html#cloud-boothook
    "#include",  # https://docs.cloud-init.io/en/24.1/explanation/format.html#include-file
    "## template: jinja",  # https://docs.cloud-init.io/en/24.1/explanation/format.html#jinja-template
    "Content-Type: multipart/mixed",  # https://docs.cloud-init.io/en/24.1/explanation/format.html#mime-multi-part-archive
    "#cloud-config-archive",  # https://docs.cloud-init.io/en/24.1/explanation/format.html#cloud-config-archive
    "#part-handler",  # https://docs.cloud-init.io/en/24.1/explanation/format.html#part-handler
    # the following option is not mentioned in the former table, but is mentioned in the vendor data:
    "#cloud-config-jsonp",  #  https://docs.cloud-init.io/en/24.1/explanation/vendordata.html
)


def _create_compute_resource_create(
    name: str,
    disk_image: str,
    flavor: str,
    userdata: str | None,
    private_ipv4: bool,
    public_ipv4: bool,
    machine_id: str | None = None,
):
    network_interfaces = []
    if private_ipv4:
        network_interfaces.append(
            NetworkInterfaceCreate(
                port_id="eth0",
                network_id="private",
                dhcp4=None,
                dhcp6=None,
                addresses=None,
                nameservers=None,
                routes=None,
            )
        )
    if public_ipv4:
        network_interfaces.append(
            NetworkInterfaceCreate(
                port_id="eth0",
                network_id="public",
                dhcp4=None,
                dhcp6=None,
                addresses=None,
                nameservers=None,
                routes=None,
            )
        )
    return ComputeResourceCreate(
        friendly_name=name,
        disk_image=disk_image,
        flavor=flavor,
        userdata=userdata,
        machine_id=machine_id,
        network_interfaces=network_interfaces,
    )


def _read_userdata(user_data: typer.FileText | None) -> str | None:
    if user_data:
        userdata = user_data.read()
        if not userdata.startswith(CLOUD_INIT_PREFIXES):
            assert user_data
            err_console.print(
                f"[orange]WARNING: Userdata file {user_data.name} does not start with a valid cloud-init prefix. :x:\n"
                f"Valid prefixes are: {', '.join(CLOUD_INIT_PREFIXES)}"
            )
        return userdata
    return None


def _get_or_create_experiment(
    ctx: BIContext,
    project_id: str,
    experiment_name_or_id: str,
    expires_at: datetime,
) -> Experiment:
    """Retrieve the experiment with at least the requested expiration time.

    If the experiment does not exist, it will be created.
    """

    if is_type_id(experiment_name_or_id, type="exp"):
        exp = ctx.obj.api.get_experiment_by_id(experiment_id=experiment_name_or_id)
    else:
        exp = ctx.obj.api.get_or_create_experiment(
            experiment_name=experiment_name_or_id,
            project_id=project_id,
            expires_at=expires_at,
        )

    # if experiment expiration is shorter than requested duration, extend the experiment
    if expires_at > exp.expires_at:
        ctx.obj.api.experiment.update(exp.id, exp.project_id, expires_at=expires_at)

    return exp


SSH_PUBKEY_PREFIXES = ["ecdsa-", "ssh-", "sk-", "rsa-"]


class SshKeyParamType(ParamType):
    """Custom SSH Key ParamType."""

    envvar_list_splitter = ","
    name = "ssh-key"

    def convert(self, value: str, param, ctx):  # noqa: ARG002
        """Convert SSH key."""
        if not any(value.startswith(prefix) for prefix in SSH_PUBKEY_PREFIXES):
            raise typer.BadParameter("Not a valid SSH public key (in OpenSSH format).")
        return value


def validate_ssh_key(pubkey: str) -> str:
    """Validate SSH public key."""
    if not any(pubkey.startswith(prefix) for prefix in SSH_PUBKEY_PREFIXES):
        raise typer.BadParameter("Not a valid SSH public key (in OpenSSH format).")
    return pubkey


def _get_ssh_keys(
    ctx: BIContext, exp: Experiment, extra_ssh_keys: list[str] | None, extra_ssh_key_files: list[typer.FileText] | None
) -> list[str]:
    """Retrieve all ssh keys that need to be passed to the BI service.

    Shows an error if none can be found.
    """
    ssh_authorized_keys = _get_registered_pubkeys(ctx, exp)
    if extra_ssh_keys:
        ssh_authorized_keys.extend(extra_ssh_keys)
    if extra_ssh_key_files:
        for file in extra_ssh_key_files or []:
            ssh_authorized_keys.append(validate_ssh_key(file.read()))

    if not ssh_authorized_keys:
        err_console.print("[red]You need to have at least one public SSH key registered. :x:")
        err_console.print("Use: [blue]slices pubkey register")
        raise typer.Exit(code=1)

    return ssh_authorized_keys


def _submit_new_resources_request(
    ctx: BIContext, exp: Experiment, nrr: NewResourcesRequest, wait: bool, use_orchestrator: bool
):
    """Submit NewResourcesRequest to BI service.

    Optionally wait for the resources to be created.

    Show resource_id in output.
    """
    try:
        if not use_orchestrator:
            response = ctx.obj.api.for_experiment(exp.id, exp.project_id).bi(ctx.obj.infra_id).resources.create(nrr)
        else:
            response = ctx.obj.api.for_experiment(exp.id, exp.project_id).bi_orchestrator.resources.create(nrr)

    except ForbiddenException as err:
        with ErrorDetailsHandler() as error_details:
            error_details.add_cur_trace()
            error_details.print("[bold red]No permission to create resource[/] :x:")
            error_details.print(f"[red]{get_api_error_detail(err)}")
            raise typer.Exit(code=1) from None
    except ApiException as err:
        with ErrorDetailsHandler() as error_details:
            error_details.add_cur_trace()
            error_details.print("[bold red]Could not create resource[/] :x:")
            error_details.print(f"[red]{get_api_error_detail(err)}")
            raise typer.Exit(code=1) from None
    if not len(response.resources):
        err_console.print("[bold red]No resources were created[/] :x:")

    if wait:
        task_results = follow_progress(
            ctx.obj.bi_tasks_api,
            [r.task_id for r in response.resources],  # type: ignore
            [f"Create resource {r.friendly_name}" for r in response.resources],
            transient=True,
        )

        task_result_statuses = [t.status for t in task_results.values()]

        has_not_enough_resources = False

        if set(task_result_statuses) != {"finished"}:
            with ErrorDetailsHandler() as error_details:
                error_details.print("[red][bold]Create resource failure")
                error_details.add_object("task_result", list(task_results.values()))
                error_details.add_object("new_resources_response", response)
                for task_result in task_results.values():
                    resource = [r for r in response.resources if r.task_id == task_result.id][0]
                    if task_result.status != "finished":
                        error_details.print_stdout(
                            f"[bold red]:x: Failed to create {resource.friendly_name} with ID {resource.id}"
                        )
                        printed_reason = False
                        if task_result.failure_reason:
                            error_details.print(f"[red]Failure: {task_result.failure_reason.code.name}")
                            error_details.print(f"[red]         {task_result.failure_reason.message}")
                            printed_reason = True
                        else:
                            # If no failure reason in the task result, look for one in the resource(s)
                            with suppress(ApiException):
                                if not use_orchestrator:
                                    updated_resource = (
                                        ctx.obj.api.for_experiment(exp.id, exp.project_id)
                                        .bi(ctx.obj.infra_id)
                                        .resources.get(resource.id)
                                    )
                                else:
                                    updated_resource = ctx.obj.api.for_experiment(
                                        exp.id, exp.project_id
                                    ).bi_orchestrator.resources.get(resource.id)
                                error_details.add_object(
                                    f"updated_resource {updated_resource.friendly_name} ({updated_resource.id})",
                                    updated_resource,
                                )
                                error_details.print(f"[red]Status: {updated_resource.status}")
                                if updated_resource.failure_reason:
                                    if updated_resource.failure_reason.code == FailureCode.NOT_ENOUGH_RESOURCES:
                                        has_not_enough_resources = True
                                    error_details.print(f"[red]Failure: {updated_resource.failure_reason.code.name}")
                                    error_details.print(f"[red]         {updated_resource.failure_reason.message}")
                                    printed_reason = True

                        logs = ctx.obj.bi_tasks_api.get_task_logs(task_result.id)
                        error_details.add_object(f"logs {resource.friendly_name} ({resource.id})", logs)
                        if logs and not printed_reason:
                            error_details.print("\n")
                            error_details.print("[red]Logs:")
                            for log_line in logs:
                                error_details.print(f"   [red]{log_line.n}: {log_line.t}")

                        error_details.print("\n")
                    else:
                        error_details.print_stdout(
                            f"[bold green]:sparkles: Successfully created {resource.friendly_name} "
                            f"with ID {resource.id}"
                        )
                error_details.print(
                    "[red]Some resources might be in the FAILED state. "
                    "They might still be online, and have not been freed yet. "
                    "You can either ask an admin to have a look, or choose to free them (slice bi destroy)."
                )
                if has_not_enough_resources:
                    # Use exit code 2 to signal NOT_ENOUGH_RESOURCES
                    raise typer.Exit(2)
                raise typer.Exit(1)

    for resource in response.resources:
        console.print(f"[bold green]:sparkles: Created {resource.friendly_name} with ID {resource.id}")


MAX_COUNT_TO_AVOID_DOS = 20

RESOURCE_NAME_REGEX = r"[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]"
RESOURCE_NAME_PATTERN = re.compile(RESOURCE_NAME_REGEX)


def _resource_name_validate_callback(ctx: typer.Context, value: str):
    """Check if a resource name is allowed in slices."""
    if ctx.resilient_parsing:
        return None

    if not RESOURCE_NAME_PATTERN.match(value):
        raise typer.BadParameter(
            "\n"
            "- Can only contain alphanumeric characters (a-z A-Z 0-9) or a minus sign (-)\n"
            "- Must be at least 2 characters long, cannot be longer than 63 characters\n"
            "- Must not start or end with a minus sign (-)"
        )
    return value


def _get_default_flavor(ctx: BIContext, infra_id: str) -> str:
    """Change defaults to good values per infrastructure."""

    if "-bi-vm" in infra_id and ctx.obj.bi_config.bi_default_vm_flavor is not None:
        return ctx.obj.bi_config.bi_default_vm_flavor
    if "-bi-baremetal" in infra_id and ctx.obj.bi_config.bi_default_baremetal_flavor is not None:
        return ctx.obj.bi_config.bi_default_baremetal_flavor
    raise ValueError(f"No default flavor for infrastructure {infra_id}")


def ensure_explicit_orchestrator(ctx: BIContext):
    """MAke sure the bi create command only uses bi-orchestrator when explicitly requested.

    This prevents it from using it by default.

    This code is a bit of a hack. In some edge cases, it does not work correctly.
    """
    if ctx.obj.infra_id == "bi-orchestrator" and "bi-orchestrator" not in sys.argv:
        err_console.print(
            "[bold red]Missing --infra-id[/bold red]"
            "\n"
            "\n"
            "You need to explicitly add the slices infrastructure ID for the bi create command. \n"
            "You can do this using the [blue]--infra-id[/blue] option, or the "
            "[blue]$SLICES_BI_INFRA_ID[/blue] env var.\n"
            # "(Do not use the alias --site-id, it is deprecated and will be removed.)\n"
            "\n"
            "Add [blue]--infra-id[/blue] directly to the [bold blue]bi[/bold blue] subcommand, "
            "not to a subcommand of the [bold blue]bi[/bold blue] subcommand, "
            "for example:\n"
            "\n"
            "   [blue]slices [bold]bi [green]--infra-id eu-example-bi-vm1[/green][/bold] create --experiment "
            "...[/blue]\n"
            "\n"
            "You can get the list of slices infrastructures IDs using the command: \n"
            "\n"
            "    [blue]slices infrastructure list[/blue]\n"
            "\n"
            "Most commands use the orchestrator by default.\n"
            "To use the orchestrator with the bi create command, explicitly set: "
            "[blue]--infra-id bi-orchestrator[/blue]\n"
        )
        raise typer.Exit(1)


DEFAULT_IMAGE_DESC = "Default Slices Image"
DEFAULT_FLAVOR_DESC = "Default Slices Flavor"


def _default_image_callback(ctx: BIContext, param: typer.CallbackParam, value: str | None) -> str:  # noqa: ARG001
    if ctx.resilient_parsing:
        return "ignored during completion"
    ensure_explicit_orchestrator(ctx)
    if value and value != DEFAULT_IMAGE_DESC:
        return value
    return ctx.obj.bi_config.bi_default_image


def _default_flavor_callback(ctx: BIContext, param: typer.CallbackParam, value: str | None) -> str:  # noqa: ARG001
    if ctx.resilient_parsing:
        return "ignored during completion"
    ensure_explicit_orchestrator(ctx)
    if value and value != DEFAULT_FLAVOR_DESC:
        return value
    return _get_default_flavor(ctx, ctx.obj.infra_id)


@bi_app.command(rich_help_panel="Commands")
@handle_some_exceptions
def create(
    ctx: BIContext,
    resource: Annotated[
        str, typer.Argument(help="Name of the resource.", show_default=False, callback=_resource_name_validate_callback)
    ],
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    image: Annotated[
        str | None, typer.Option(help="ID or friendly name of the disk image.", callback=_default_image_callback)
    ] = DEFAULT_IMAGE_DESC,
    flavor: Annotated[
        str, typer.Option(help="ID or friendly name of the flavor", callback=_default_flavor_callback)
    ] = DEFAULT_FLAVOR_DESC,
    expires_at: Annotated[
        datetime,
        typer.Option(
            "--duration",
            "-d",
            callback=_validate_duration_option,
            help="Lifetime of the resource. Can be extended later on.",
            parser=duration_or_datetime,
        ),
    ] = "3h",  # type: ignore
    user_data: Annotated[
        typer.FileText | None, typer.Option(help="File with cloudinit user-data.", show_default=False)
    ] = None,
    wait: Annotated[
        bool, typer.Option("--wait", help="Wait till resource is ready after creation.", show_default=False)
    ] = False,
    private_ipv4: Annotated[
        bool, typer.Option("--private-ipv4", help="Request a private IPv4.", show_default=False)
    ] = False,
    public_ipv4: Annotated[
        bool, typer.Option("--public-ipv4", help="Request a public IPv4.", show_default=False)
    ] = False,
    count: Annotated[int, typer.Option(help="Number of resources to create.", show_default=False)] = 1,
    allow_large_counts: Annotated[bool, typer.Option("--allow-large-counts", hidden=True, show_default=False)] = False,
    ssh_key: Annotated[
        list[str] | None,
        typer.Option(
            help="(Extra) SSH public key to register for login.",
            show_default=False,
            envvar="SLICES_SSH_KEY",
            click_type=SshKeyParamType(),
        ),
    ] = None,
    ssh_key_file: Annotated[
        list[typer.FileText] | None,
        typer.Option(
            help="(Extra) SSH public key file to use for login.", show_default=False, envvar="SLICES_SSH_KEY_FILE"
        ),
    ] = None,
    machine_id: Annotated[
        str | None,
        typer.Option(help="If creating a bare metal, force it to be the machine matching this ID.", show_default=False),
    ] = None,
):
    """Request basic infrastructure (BI) resource."""
    ensure_explicit_orchestrator(ctx)

    project_id = ctx.obj.require_project_id()
    exp = _get_or_create_experiment(ctx, project_id, experiment, expires_at)
    ssh_authorized_keys = _get_ssh_keys(ctx, exp, ssh_key, ssh_key_file)

    disk_image_id = _find_disk_image_id(ctx, image)
    flavor_id = _find_flavor_id(ctx, flavor)

    # if machine_id is not an ID, it is a name we need to resolve to ID
    if machine_id and not is_type_id(machine_id, type="m"):
        machines = ctx.obj.api.for_project(ctx.obj.require_project_id()).bi(ctx.obj.infra_id).machine.list()
        machine_name = machine_id
        machine_id = None
        for machine in machines:
            if machine.name == machine_name:
                machine_id = machine.id
                break
        if not machine_id:
            err_console.print(f"[red]Could not find machine with name '{machine_name}' :x:")
            raise typer.Exit(code=1)

    resources: list[ComputeResourceCreate] = []
    userdata = _read_userdata(user_data)
    if count == 1:
        resources.append(
            _create_compute_resource_create(
                resource, disk_image_id, flavor_id, userdata, private_ipv4, public_ipv4, machine_id
            )
        )
    elif count > MAX_COUNT_TO_AVOID_DOS and not allow_large_counts:
        # to make sure that someone can not just use 1000, 'just for trying' (as long as we have no quota system)
        # The hidden option --allow-large-counts allows it, for cases like stresstest.
        #    --allow-large-counts is not in help, so you need to know about it.
        err_console.print("[red]For this amount, please use create-from-file. :x:")
        err_console.print("See https://doc.slices-ri.eu/BasicServices/BasicInfrastructure/resource_spec.html")
        raise typer.Exit(code=1)
    else:
        if machine_id is not None:
            err_console.print("[red]--machine-id makes no sense combined with --count. :x:")
            raise typer.Exit(code=1)

        resources.extend(
            # bvermeul: range 0-... is okay, we're in computer science + in line with jFed node0, etc
            _create_compute_resource_create(
                f"{resource}{i}", disk_image_id, flavor_id, userdata, private_ipv4, public_ipv4
            )
            for i in range(count)
        )

    audience = (
        ctx.obj.api.bi_config.bi_infrastructures[ctx.obj.infra_id].audience
        if ctx.obj.infra_id != "bi-orchestrator"
        else ctx.obj.api.bi_config.bi_infrastructures["bi-orchestrator"].audience
    )

    nrr = NewResourcesRequest(
        expires_at=expires_at,
        resources=resources,
        ssh_authorized_keys=ssh_authorized_keys,
        tokens=[Token(jwt=ctx.obj.api.get_id_token_for_audience(audience, project_id))],
    )

    _submit_new_resources_request(ctx, exp, nrr, wait, use_orchestrator=False)


@bi_app.command(rich_help_panel="Commands")
@handle_some_exceptions
def create_from_file(
    ctx: BIContext,
    file: Annotated[typer.FileText, typer.Argument(..., help="File with resource definitions.", show_default=False)],
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    expires_at: Annotated[
        datetime,
        typer.Option(
            "--duration",
            "-d",
            callback=_validate_duration_option,
            help="Lifetime of the resource. Can be extended later on.",
            parser=duration_or_datetime,
        ),
    ] = "3h",  # type: ignore
    wait: Annotated[
        bool, typer.Option("--wait", help="Wait till resources are ready after creation.", show_default=False)
    ] = False,
    ssh_key: Annotated[
        list[str] | None,
        typer.Option(
            help="(Extra) SSH public key to register for login.",
            show_default=False,
            envvar="SLICES_SSH_KEY",
            click_type=SshKeyParamType(),
        ),
    ] = None,
    ssh_key_file: Annotated[
        list[typer.FileText] | None,
        typer.Option(
            help="(Extra) SSH public key file to use for login.", show_default=False, envvar="SLICES_SSH_KEY_FILE"
        ),
    ] = None,
):
    """Request basic infrastructure (BI) resources, described in a file.

    The file can override the infra-id (argument or env var) per resource.

    Unless only resources on a single infrastructure are requested,
    """
    infra_id, request = _read_and_process_request_file(ctx, file)
    use_orchestrator = infra_id is None
    if infra_id and infra_id != ctx.obj.infra_id:
        ctx.obj.infra_id = infra_id

    project_id = ctx.obj.require_project_id()
    exp = _get_or_create_experiment(ctx, project_id, experiment, expires_at)
    ssh_authorized_keys = _get_ssh_keys(ctx, exp, ssh_key, ssh_key_file)

    if use_orchestrator:
        token = ctx.obj.api.get_id_token_for_audience(
            ctx.obj.api.bi_config.bi_infrastructures["bi-orchestrator"].audience, project_id
        )
    else:
        token = ctx.obj.api.get_id_token_for_audience(
            ctx.obj.api.bi_config.bi_infrastructures[ctx.obj.infra_id].audience, project_id
        )

    nrr = NewResourcesRequest(
        expires_at=expires_at,
        resources=request.resources,
        links=request.links,
        ssh_authorized_keys=ssh_authorized_keys,
        tokens=[Token(jwt=token)],
    )

    _submit_new_resources_request(ctx, exp, nrr, wait, use_orchestrator)


class ListResourcesFormat(Enum):
    """List Resources Formats."""

    table = "table"
    json = "json"
    yaml = "yaml"
    ansible = "ansible"


class EnrichedComputeResource(ComputeResource):
    """Compute Resource Base extended with machine_name."""

    infra_id: str | None = None
    machine_name: str | None = None


def _enrich_compute_resource(resource: ComputeResource, machines_by_id: dict[str, Machine]):
    machine = machines_by_id.get(resource.machine_id) if resource.machine_id else None
    machine_name = machine.name if machine else None
    return EnrichedComputeResource(
        **asdict(resource), machine_name=machine_name, infra_id=get_type_id_domain(resource.id)
    )


class ListResourceSort(Enum):
    """List sort options."""

    name = "name"  # pyright: ignore [reportIncompatibleMethodOverride,reportAssignmentType] seems to work fine
    status = "status"
    created = "created"
    expires = "expires"
    machine = "machine"


def _sort_resources(resources: list[EnrichedComputeResource], sort: ListResourceSort):
    if sort == ListResourceSort.name:
        resources.sort(key=lambda r: (r.infra_id, r.friendly_name, r.created_at))
    elif sort == ListResourceSort.created:
        resources.sort(key=lambda r: (r.infra_id, r.created_at, r.friendly_name))
    elif sort == ListResourceSort.status:
        resources.sort(key=lambda r: (r.infra_id, str(r.status), r.friendly_name))
    elif sort == ListResourceSort.expires:
        resources.sort(key=lambda r: (r.infra_id, r.expires_at, r.friendly_name))
    elif sort == ListResourceSort.machine:
        resources.sort(key=lambda r: (r.infra_id, r.machine_name, r.friendly_name))


@bi_app.command("list-resources | list", rich_help_panel="Commands")
@handle_some_exceptions
def list_experiment_resources(
    ctx: BIContext,
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    resources: Annotated[list[str] | None, typer.Argument(help="Resource names or IDs.", show_default=False)] = None,
    include_expired: Annotated[
        bool, typer.Option("--show-expired", "-e", help="Include resources that have expired.", show_default=False)
    ] = False,
    include_terminated: Annotated[
        bool, typer.Option("--show-deleted", "-d", help="Include resources that were deleted.", show_default=False)
    ] = False,
    format: Annotated[
        ListResourcesFormat,
        typer.Option("--format", "-f", help="Format in which the resources should be listed."),
    ] = "table",  # type: ignore
    sort: Annotated[
        ListResourceSort, typer.Option("--sort", "-s", help="Field to sort by (infra_id is always first sort field).")
    ] = "created",  # type: ignore
):
    """List basic infrastructure details of BI resources in an experiment."""
    exp = bi_get_experiment(ctx, experiment)

    if exp.expires_at < datetime.now(timezone.utc):
        err_console.print(f"[red]Experiment {exp.friendly_name} has expired :x:")
        raise typer.Exit(1)

    resources_to_show: Collection[ComputeResource] = _get_requested_bi_resources(
        ctx, exp, resources, include_expired, include_terminated
    )

    machines_by_id: dict[str, Machine] = {}
    has_machine_ids = any(r for r in resources_to_show if r.machine_id)
    has_failure_reason = any(r for r in resources_to_show if r.failure_reason)
    if has_machine_ids:
        # if there are machine IDs, fetch machine info
        try:
            machines_by_id = {
                m.id: m
                for m in ctx.obj.api.for_project(ctx.obj.require_project_id()).bi(ctx.obj.infra_id).machine.list()
            }
        except ApiException:
            with ErrorDetailsHandler() as error_details:
                err_console.print("[red]Warning: Problem fetching extra machine info.")
                err_console.print("[red]         The resource info will not contain machine details.")
                error_details.add_cur_trace()
            machines_by_id = {}
    eresources_to_show: list[EnrichedComputeResource] = [
        _enrich_compute_resource(r, machines_by_id) for r in resources_to_show
    ]

    _sort_resources(eresources_to_show, sort)

    if format == ListResourcesFormat.table:
        cols = [
            "ID",
            "Name",
            "Status",
            "Created At",
            "Expires At",
            "Public IPv4",
            "Private IPv4",
        ]
        if has_machine_ids:
            cols.append("Machine")
        if has_failure_reason:
            cols.append("Fail Code")
        table = Table(*cols, title=f"Resources in experiment {exp.id}")
        has_failure = False
        for r in eresources_to_show:
            rows = [
                r.id,
                r.friendly_name,
                r.status,
                friendly_datetime(r.created_at.astimezone()),
                friendly_datetime(r.expires_at.astimezone()) if r.expires_at else "-",
                r.public_ipv4,
                r.private_ipv4,
            ]
            if has_machine_ids:
                rows.append(r.machine_name)
            if has_failure_reason:
                rows.append(r.failure_reason.code.name if r.failure_reason else "")
            table.add_row(*rows)
            if r.status == ResourceState.FAILED:
                has_failure = True
        console.print(table)
        if has_failure:
            console.print(
                "[red]Some resources are in the FAILED state. \n"
                "The `slices bi show <id>` command might provide more details about the failure. \n"
                "The resources might still be online, and have not been freed yet. "
                "You can either ask an admin to have a look, or choose to free them (slice bi destroy)."
            )

    elif format == ListResourcesFormat.json:
        console.print_json(json_encode(list(eresources_to_show)).decode())
    elif format == ListResourcesFormat.ansible:
        no_ssh_login_count = 0
        for compute_resource in eresources_to_show:
            # output without any markup
            ssh_login, can_connect = bi_get_best_ssh_login(compute_resource, with_proxy=False)
            if ssh_login:
                typer.echo(_resource_to_ansible(compute_resource, ssh_login, proxy=not can_connect))
            else:
                no_ssh_login_count += 1
        if no_ssh_login_count:
            err_console.print(f"[yellow]Ignored {no_ssh_login_count} resources without SSH info :warning:")
    elif format == ListResourcesFormat.yaml:
        console.print(yaml_encode(list(eresources_to_show)).decode())


def _get_resource(ctx: BIContext, experiment: Experiment, resource_id: str) -> ComputeResource:
    """Get a resource ID."""
    if is_type_id(resource_id, type="r"):
        try:
            return (
                ctx.obj.api.for_experiment(experiment.id, experiment.project_id)
                .bi(ctx.obj.infra_id)
                .resources.get(resource_id)
            )

        except NotFoundException as err:
            err_console.print(
                f"[red]Resource {resource_id} not found in experiment {experiment.friendly_name} "
                f"on infrastructure {ctx.obj.infra_id} :x:"
            )
            raise typer.Exit(code=1) from err

    resources = ctx.obj.get_bi_resources_api_by_experiment(experiment).list()

    inactive_resource = None

    def _is_inactive(resource: ComputeResource) -> bool:
        return resource.status in (ResourceState.DELETED, ResourceState.FAILED)

    for resource in resources:
        if resource.friendly_name.lower() == resource_id.lower():
            if _is_inactive(resource):
                if (
                    # no inactive resource with this name found yet
                    not inactive_resource
                    # more recent inactive resource has precedence
                    or resource.created_at > inactive_resource.created_at
                ):
                    inactive_resource = resource
            else:
                return resource

    # no active resource found, return inactive resource if found
    if inactive_resource:
        return inactive_resource

    err_console.print(
        f"[red]Resource {resource_id} not found in experiment {experiment.friendly_name} "
        f"on infrastructure {ctx.obj.infra_id} :x:"
    )
    raise typer.Exit(code=1)


class ShowResourceFormat(Enum):
    """Show resource format Options."""

    text = "text"
    json = "json"
    yaml = "yaml"


BASIC_RESOURCE_FIELDS = {
    "friendly_name": "Name",
    "expires_at": "Expires At",
    "status": "Status",
}


def _unnest_jump_proxies(first_jump_proxy: SSHInfo | None) -> list[SSHInfo]:
    """Return a list of all jump proxies, instead of the nested representation.

    This is easier to use in code.
    The list is also reversed, that is, the jump proxy all others depend on, comes first.
    """

    res = []
    # The loop below should always be finite.
    # But it is always best to have a safety.
    # Because infinite loops cause end user bugs that are hard to debug.
    safety = 5
    jump_proxy = first_jump_proxy
    while jump_proxy:
        res.insert(0, jump_proxy)
        jump_proxy = jump_proxy.jump_proxy
        safety -= 1
        if safety <= 0:
            with ErrorDetailsHandler() as error_details:
                error_details.print("[red]BUG: Too much loops handling jump proxy :x:")
                error_details.print("[red]Please report this bug!")
                error_details.add_object("first_jump_proxy", first_jump_proxy)
                raise typer.Exit(code=1)

    return res


def _make_jump_proxy_cli_args_and_config(unnested_jump_proxies: list[SSHInfo]) -> tuple[list[str], str]:
    ssh_jump_proxy_destinations = []
    for jump_proxy in unnested_jump_proxies:
        port_dest = f":{jump_proxy.port}" if jump_proxy.port != DEFAULT_SSH_PORT else ""
        ssh_jump_proxy_destinations.append(f"{jump_proxy.username}@{jump_proxy.host}{port_dest}")

    return ["-J", ",".join(ssh_jump_proxy_destinations)], f"ProxyJump {','.join(ssh_jump_proxy_destinations)}"


def _get_jump_ssh_arg(jump: SSHInfo) -> list[str]:
    jump_proxies = _unnest_jump_proxies(jump)
    jump_proxy_cli_args, _ = _make_jump_proxy_cli_args_and_config(jump_proxies)
    return jump_proxy_cli_args


def _print_ssh_login(ssh_login: SSHInfo):
    params = []

    if ssh_login.port != DEFAULT_SSH_PORT:
        params.extend(["-p", f"{ssh_login.port}"])
    if ssh_login.jump_proxy:
        params.extend(_get_jump_ssh_arg(ssh_login.jump_proxy))

    return f"ssh{(' ' + ' '.join(params)) if params else ''} {ssh_login.username}@{ssh_login.host}"


@bi_app.command("show", rich_help_panel="Commands")
@handle_some_exceptions
def show_resource(
    ctx: BIContext,
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    resource: Annotated[str, typer.Argument(help="Resource name or ID.", show_default=False)],
    format: Annotated[
        ShowResourceFormat, typer.Option("--format", "-f", help="Format in which the info should be shown.")
    ] = "text",  # pyright: ignore [reportArgumentType]  weird but this works
):
    """Show single resource details."""

    exp = bi_get_experiment(ctx, experiment)
    r = bi_get_resource(ctx, exp, resource)

    if format == ShowResourceFormat.text:
        table = Table(box=None)
        table.add_column("Name", justify="right")
        table.add_column("Value")

        table.add_row("ID", r.id)
        table.add_row("Name", r.friendly_name)
        table.add_row("Status", r.status)

        if r.failure_reason:
            table.add_row("Failure Code", r.failure_reason.code)
            table.add_row("Failure Message", r.failure_reason.message)
            if r.failure_reason.long_description:
                table.add_row("Failure Details", r.failure_reason.long_description)

        # IP info
        if r.public_ipv4:
            table.add_row("Public IPv4", r.public_ipv4)
        if r.private_ipv4:
            table.add_row("Private IPv4", r.private_ipv4)
        if r.public_ipv6:
            table.add_row("Public IPv6", r.public_ipv6)
        if r.private_ipv6:
            table.add_row("Private IPv6", r.private_ipv6)

        # Dates
        table.add_row("Expires At", friendly_datetime(r.expires_at) if r.expires_at else "-")
        table.add_row("Created At", friendly_datetime(r.created_at))
        if r.terminated_at:
            table.add_row("Deleted At", friendly_datetime(r.terminated_at))

        flavor = ctx.obj.bi_flavors_api.get(r.flavor_id)
        if flavor:
            table.add_row("Flavor", flavor.friendly_name)
        table.add_row("Flavor ID", r.flavor_id)

        disk_image = ctx.obj.bi_image_api.get(r.disk_image_id)

        if disk_image:
            table.add_row("Disk Image", disk_image.friendly_name)
        table.add_row("Disk Image ID", r.disk_image_id)

        if r.machine_id:
            table.add_row("Machine ID", r.machine_id)
            try:
                machine = (
                    ctx.obj.api.for_project(ctx.obj.require_project_id()).bi(ctx.obj.infra_id).machine.get(r.machine_id)
                )
                if machine and machine.name:
                    table.add_row("Machine Name", machine.name)
                else:
                    table.add_row("Machine Name", "Error: Empty")
            except NotFoundException:
                table.add_row("Machine Name", "Error: Not Found")

        if r.ssh_logins:
            table.add_row(
                "SSH Logins",
                "\n".join(_print_ssh_login(ssh_login) for ssh_login in r.ssh_logins),
            )

            host_keys: set[str] = set()
            for ssh_login in r.ssh_logins:
                if ssh_login.host_keys:
                    host_keys.update(hk.strip() for hk in ssh_login.host_keys)

            if host_keys:
                # table.add_row("SSH Hostkeys", "\n".join(host_keys))
                table.add_row("SSH Hostkey fingerprints", "\n".join(sha256_fingerprint(hk) for hk in host_keys))

        console.print(table)
    elif format == ShowResourceFormat.json:
        console.print_json(json_encode(r).decode())
    elif format == ShowResourceFormat.yaml:
        console.print(yaml_encode(r).decode())
    else:
        raise ValueError(f"Unimplemented format requested: {format}")


@bi_app.command("reset", rich_help_panel="Commands")
@handle_some_exceptions
def reset_resource(
    ctx: BIContext,
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    resource: Annotated[str, typer.Argument(help="Resource name or ID.", show_default=False)],
):
    """Hard reset a resource."""
    exp = bi_get_experiment(ctx, experiment)
    res = bi_get_resource(ctx, exp, resource)
    _check_resource_available_for_action(res)

    tasks = ctx.obj.get_bi_resources_api_by_experiment(exp).reboot(res.id, "HARD")

    project_id = ctx.obj.get_current_project_id()
    if not project_id:
        raise typer.Exit(0)

    follow_progress(
        ctx.obj.bi_tasks_api,
        [task.id for task in tasks],
        [f"Resetting {resource}"] * len(tasks),
    )


@bi_app.command("destroy", rich_help_panel="Commands")
@handle_some_exceptions
def destroy_resource(
    ctx: BIContext,
    # The env var for experiment is NOT used to really force people in writing --experiment
    experiment: Annotated[str, typer.Option(..., help="Experiment name or ID.", prompt=True)],
    resources: Annotated[list[str] | None, typer.Argument(help="Resource names or IDs.", show_default=False)] = None,
    force: Annotated[bool, typer.Option("--force", help="Do not ask for confirmation. Be careful !")] = False,
    yesallofthem: Annotated[
        bool,
        typer.Option(
            "--yes-all-of-them",
            help="All BI resources in this experiment on this infrastructure. Be careful!",
        ),
    ] = False,
):
    """Destroy one or more basic infrastructure resources.

    If one or more resources are given, destroy them without asking for confirmation.

    If no resources are given and an explicit --yes-all-of-them, then destroy all resources in the experiment.
    Ask for confirmation for each resource, unless --force is used.
    """
    exp = bi_get_experiment(ctx, experiment)

    resources_to_delete: Collection[ComputeResource] = _get_requested_bi_resources(
        ctx, exp, resources, include_expired=False, include_terminated=False
    )

    if not resources_to_delete:
        console.print("[yellow]No active resources found to destroy :warning:")
        raise typer.Exit(0)

    all_resources_in_exp = not bool(resources)

    if all_resources_in_exp and not yesallofthem:
        err_console.print(
            f"[red]If you really want to destroy all BI resources in this experiment on infrastructure "
            f"{ctx.obj.infra_id}, please add --yes-all-of-them :x:"
        )
        return

    if all_resources_in_exp and not force:
        table = Table(
            "ID",
            "Name",
            "Status",
            "Created At",
            "Expires At",
            "Public IPv4",
            "Private IPv4",
            title=f"Resources to delete on infrastructure {ctx.obj.infra_id} in experiment {exp.id}",
        )
        for r in resources_to_delete:
            table.add_row(
                r.id,
                r.friendly_name,
                r.status,
                friendly_datetime(r.created_at.astimezone()),
                friendly_datetime(r.expires_at.astimezone()) if r.expires_at else "-",
                r.public_ipv4,
                r.private_ipv4,
            )
        console.print(table)

    skip_confirmation = bool(resources) or force
    delete_task_info: list[tuple[str, str, ComputeResource]] = []

    already_deleted_count = 0
    most_recently_terminated_resource: ComputeResource | None = None

    for resource in resources_to_delete:
        was_already_deleted = resource.status == ResourceState.DELETED and resource.terminated_at
        if was_already_deleted:
            already_deleted_count += 1
            if most_recently_terminated_resource is None or (
                resource.terminated_at
                and most_recently_terminated_resource.terminated_at
                and resource.terminated_at > most_recently_terminated_resource.terminated_at
            ):
                most_recently_terminated_resource = resource
        if not skip_confirmation:
            ok = Prompt.ask(
                f"Are you sure that you want to [red bold]delete[/] resource [italic]{resource.friendly_name}[/]?",
                choices=["y", "n", "all"],
            )

            if ok == "n":
                continue
            if ok == "all":
                skip_confirmation = True

        delete_tasks = ctx.obj.get_bi_resources_api_by_experiment(exp).delete_by_id(resource.id)
        delete_task_info.extend(
            [(f"Deleting {resource.friendly_name}", delete_task.id, resource) for delete_task in delete_tasks]
        )
        try:
            SSHKnownHostsManager().cleanup(resource.id)
        except Exception:
            err_console.print(f"Could not remove ssh host key for {resource.friendly_name}")

    task_results = follow_progress(
        ctx.obj.bi_tasks_api,
        [task_id for _, task_id, _ in delete_task_info],
        [task_name for task_name, _, _ in delete_task_info],
    )
    task_result_statuses = [t.status for t in task_results.values()]

    if set(task_result_statuses) != {"finished"}:
        with ErrorDetailsHandler() as error_details:
            error_details.print("[red bold]Destroy failure :x:")
            error_details.add_object("task_results", task_results)
            error_details.add_object("delete_task_info", delete_task_info)
            for task_result in task_results.values():
                resource = [r for _, task_id, r in delete_task_info if task_id == task_result.id][0]
                if task_result.status != "finished":
                    console.print(f"[bold red]:x: Failed to destroy {resource.friendly_name} with ID {resource.id}")
                    if task_result.failure_reason:
                        error_details.print(f"[red]Failure: {task_result.failure_reason.code.name}")
                        error_details.print(f"[red]         {task_result.failure_reason.message}")
                    logs = ctx.obj.bi_tasks_api.get_task_logs(task_result.id)
                    error_details.add_object(f"logs for task {task_result.id}", logs)
                    if not task_result.failure_reason and logs:
                        error_details.print(f"[red]{logs}")
                else:  # noqa: PLR5501
                    # Show that all is successful
                    if resource.status == ResourceState.DELETED and resource.terminated_at:
                        error_details.print_stdout(
                            f"[bold green]:recycle: Already destroyed {resource.friendly_name} with ID {resource.id} "
                            f"on {friendly_datetime(resource.terminated_at)}"
                        )
                    else:
                        error_details.print_stdout(
                            f"[bold green]:recycle: Successfully destroyed {resource.friendly_name} "
                            f"with ID {resource.id}"
                        )
            raise typer.Exit(1)

    newly_deleted_count = len(resources_to_delete) - already_deleted_count

    if already_deleted_count and most_recently_terminated_resource and most_recently_terminated_resource.terminated_at:
        humanized_delta = humanize.precisedelta(
            most_recently_terminated_resource.terminated_at - datetime.now(UTC),
            minimum_unit="minutes",
            format="%0.0f",
        )

        if already_deleted_count > 1:
            console.print(
                f"[green] :recycle: {already_deleted_count} "
                f"resources "
                f"on infrastructure {ctx.obj.infra_id} in experiment {exp.id} were previously destroyed."
                f" (most recently on {friendly_datetime(most_recently_terminated_resource.terminated_at)} = "
                f"{humanized_delta} ago)"
            )
        else:
            console.print(
                f"[green] :recycle: {already_deleted_count} "
                f"resource{'s' if already_deleted_count > 1 else ''} "
                f"on infrastructure {ctx.obj.infra_id} in experiment {exp.id} was previously destroyed, "
                f"on {friendly_datetime(most_recently_terminated_resource.terminated_at)} ({humanized_delta} ago)"
            )
    if newly_deleted_count:
        console.print(
            f"[green] :recycle: Destroyed {newly_deleted_count} "
            f"resource{'s' if newly_deleted_count > 1 else ''} "
            f"on infrastructure {ctx.obj.infra_id} in experiment {exp.id}."
        )


def _print_experiment_and_project_expiration_info(ctx: BIContext, exp: Experiment, new_expires_at: datetime):
    def hum(d: datetime) -> str:
        return humanize.precisedelta(
            d - datetime.now(UTC),
            minimum_unit="hours",
            format="%d",
        )

    console.print(
        f":clock10: Experiment currently expires at {friendly_datetime(exp.expires_at)} (in {hum(exp.expires_at)})."
    )

    console.print(
        f":clock10: Experiment expiration requested is {friendly_datetime(new_expires_at)} (in {hum(new_expires_at)})."
    )

    current_project_id = ctx.obj.get_current_project_id()
    project = ctx.obj.api.project.get(current_project_id) if current_project_id else None
    if project and project.expires_at:
        if project.expires_at < datetime.now(UTC):
            console.print(
                f"[red]:clock10: Project expired on "
                f"{friendly_datetime(project.expires_at)} ({hum(project.expires_at)} ago)."
            )
        else:
            console.print(
                f":clock10: Project expires on {friendly_datetime(project.expires_at)} (in {hum(project.expires_at)})"
            )


def _is_ok_emo(expires_at: datetime | None, new_expires_at: datetime) -> str:
    if expires_at is None:
        return ""
    if expires_at < new_expires_at:
        return "[red]:x:"
    return "[green]:heavy_check_mark:"


def _is_ok_color(expires_at: datetime | None, new_expires_at: datetime) -> str:
    if expires_at is None:
        return ""
    if expires_at < new_expires_at:
        return "[red]"
    return "[green]"


def _show_expauth_resources_overview(
    ctx: BIContext,
    exp: Experiment,
    new_expires_at: datetime,
    resources: list[str] | None,
    has_errors: int,
    error_details: ErrorDetailsHandler,
):
    # Get the requested resources[] on the BI
    bi_resources: Collection[ComputeResource] = _get_requested_bi_resources(
        ctx, exp, resources, include_expired=False, include_terminated=False
    )

    # Get all resources in experiment
    all_expauth_resources = ctx.obj.api.experiment.list_resources(
        experiment_id=exp.id,
        include_expired=False,
        include_terminated=False,
    )
    if resources:
        all_bi_ids = [r.id for r in bi_resources]
        all_expauth_resources = [r for r in all_expauth_resources if r.id in all_bi_ids]

    bi_resources_by_id = {r.id: r for r in bi_resources}

    all_expauth_ids = [r.id for r in all_expauth_resources]
    bi_resources_not_on_expauth = [r for r in bi_resources if r.id not in all_expauth_ids]

    cols = [
        "Friendly Name",
        "Infra",
        "Type",
        "Status",
        "Description",
        "Created At",
    ]
    if has_errors:
        cols.insert(0, "ID")
        cols.append("Expires At (BI)")
        cols.append("Expires At (ExpAuth)")
    else:
        cols.append("Expires At")

    table = Table(
        *cols,
        title=f"Overview of extended resources in experiment {exp.friendly_name}",
    )

    for expauth_r in all_expauth_resources:
        needs_to_be_extended = ctx.obj.infra_id and expauth_r.infra_id == ctx.obj.infra_id
        expires_at_expauth_row = friendly_datetime(expauth_r.expires_at) if expauth_r.expires_at else ""
        if has_errors and expauth_r.expires_at:
            expires_at_expauth_row = (
                (
                    f"{_is_ok_color(expauth_r.expires_at, new_expires_at)}"
                    f"{friendly_datetime(expauth_r.expires_at)} "
                    f"{_is_ok_emo(expauth_r.expires_at, new_expires_at)}"
                )
                if needs_to_be_extended
                else f"{friendly_datetime(expauth_r.expires_at)}"
            )

        bi_r = bi_resources_by_id.get(expauth_r.id)
        expires_at_bi_row = friendly_datetime(bi_r.expires_at) if bi_r and bi_r.expires_at else ""
        if bi_r and bi_r.expires_at and has_errors:
            expires_at_bi_row = (
                f"{_is_ok_color(bi_r.expires_at, new_expires_at)}"
                f"{friendly_datetime(bi_r.expires_at)} "
                f"{_is_ok_emo(bi_r.expires_at, new_expires_at)}"
            )

        row = [
            expauth_r.friendly_name,
            expauth_r.infra_id,
            expauth_r.type,
            expauth_r.status,
            expauth_r.description,
            friendly_datetime(expauth_r.created_at),
        ]
        if has_errors:
            row.insert(0, expauth_r.id)
            row.append(expires_at_bi_row)
            row.append(expires_at_expauth_row)
        else:
            row.append(expires_at_expauth_row)

        table.add_row(*row)

    for bi_r in bi_resources_not_on_expauth:
        expires_at_expauth_row = "[red]MISSING[/red]"

        expires_at_bi_row = friendly_datetime(bi_r.expires_at) if bi_r and bi_r.expires_at else ""
        if bi_r and bi_r.expires_at and has_errors:
            expires_at_bi_row = (
                f"{_is_ok_color(bi_r.expires_at, new_expires_at)}"
                f"{friendly_datetime(bi_r.expires_at)} "
                f"{_is_ok_emo(bi_r.expires_at, new_expires_at)}"
            )

        row = [
            bi_r.friendly_name,
            ctx.obj.infra_id,
            "",
            bi_r.status,
            "",
            friendly_datetime(bi_r.created_at),
        ]
        if has_errors:
            row.insert(0, bi_r.id)
            row.append(expires_at_bi_row)
            row.append(expires_at_expauth_row)
        else:
            row.append(expires_at_expauth_row)

        table.add_row(*row)

    error_details.print_stdout(table)

    if bi_resources_not_on_expauth:
        error_details.print_stdout(
            "[red]Warning: The info shown by `slices experiment list-resources` may not (yet) be up to date."
        )


def _wait_check_expauth_resources(
    ctx: BIContext, exp: Experiment, new_expires_at: datetime, resource_ids: list[str] | None
) -> int:
    """Wait for up to 10 seconds for the expauth resources to have correct expires_at.

    resources can be empty if we want to extend all resources
    Return the number of resources with correct expires_at on the active infrastructure
    """
    max_wait_time = 10  # seconds
    wait_between_poll = 2  # seconds

    deadline = datetime.now(UTC) + timedelta(seconds=max_wait_time)

    while datetime.now(UTC) < deadline:
        expauth_resources = ctx.obj.api.experiment.list_resources(
            experiment_id=exp.id,
            include_expired=False,
            include_terminated=False,
        )

        all_ready = True  # becomes False if a resource is found that should be extended, but is not updated yet
        count = 0

        for resource in expauth_resources:
            # we run over all resources in the experiment on this infrastructure and as long we find resources that
            # should be extended (all if resources is empty or the resources in resources[]), but aren't, we keep
            # retrying
            if (not resource_ids) or resource.id in resource_ids:
                if resource.expires_at and resource.expires_at < new_expires_at:
                    all_ready = False  # this resource has not been updated yet
                else:
                    count += 1

        if all_ready:  # and count > (len(resources or []) or 1):
            return count
        err_console.print(".", end="")
        sleep(wait_between_poll)

    return 0


# def _show_resource_overview():
#     if has_errors:
#         console.print(f"\n[blue]Infra {ctx.obj.infra_id} reported successful resource extend.")
#         # console.print("Verifying...")
#         # err_console.print("[red][bold]Resources were not correctly extended! :x:\n")
#         err_console.print(
#           "[red][bold]An additional resource status check indicated that the extend was actually not successful! :x:"
#         )
#         err_console.print("[red]Please contact support with the following details:\n")
#
#         table = Table("Name", "Expires at", "ID", title="Expiration Details", show_header=True)
#         table.add_row(
#             "Requested",
#             f"{friendly_datetime(new_expires_at)}",
#             "",
#         )
#         table.add_row(
#             f"Experiment {refreshed_experiment.friendly_name}",
#             f"{_is_ok_color(refreshed_experiment.expires_at, new_expires_at)}"
#             f"{friendly_datetime(refreshed_experiment.expires_at)} "
#             f"{_is_ok_emo(refreshed_experiment.expires_at, new_expires_at)}",
#             refreshed_experiment.id,
#         )
#         for r in refreshed_resources:
#             if r.expires_at:
#                 table.add_row(
#                     f"Resource [i]{r.friendly_name}[/i]",
#                     f"{_is_ok_color(r.expires_at, new_expires_at)}{friendly_datetime(r.expires_at)} "
#                     f"{_is_ok_emo(r.expires_at, new_expires_at)}",
#                     r.id,
#                 )
#
#         console.print(table)


def _show_experiment_extend_check_fail(
    ctx: BIContext, exp: Experiment, new_expires_at: datetime, error_details: ErrorDetailsHandler
):
    error_details.print(f"\n[blue]Infrastructure {ctx.obj.infra_id} reported successful resource extend.")
    error_details.print(
        "[red][bold]An additional resource status check indicated that the extend was actually not successful! :x:"
    )
    error_details.print("[red]Please contact support with the following details:\n")
    error_details.print(f"[default]Experiment ID: {exp.id}", highlight=False)
    error_details.print(
        f"[default]Requested Expire: {friendly_datetime(new_expires_at)}",
        highlight=False,
    )
    if exp.expires_at < new_expires_at:
        error_details.print(f"Actual Experiment Expire: [red]{friendly_datetime(exp.expires_at)} :x:")
    else:
        error_details.print(f"Actual Experiment Expire: [green]{friendly_datetime(exp.expires_at)} :heavy_check_mark:")


def _check_extend_resources(
    ctx: BIContext,
    exp: Experiment,
    new_expires_at: datetime,
    resource_ids: list[str] | None,
    *,
    must_check_expauth_extend: bool,
):
    # Get current experiment and resources
    # len(resources)==0 -> extend all resources on this infrastructure
    has_errors = 0

    refreshed_experiment = bi_get_experiment(ctx, exp.id)

    if refreshed_experiment.expires_at < new_expires_at:
        has_errors += 1

    # if input resources = [] (request for all): refreshed_resources will have all resources on this infrastructure
    # otherwise, only the resources that are needed to extend
    refreshed_resources: Collection[ComputeResource] = _get_requested_bi_resources(
        ctx, exp, resource_ids, include_expired=False, include_terminated=False
    )

    if any(r.expires_at < new_expires_at for r in refreshed_resources if r.expires_at):
        has_errors += 1

    if len(resource_ids or []) > 0 and len(refreshed_resources) != len(resource_ids or []):
        has_errors += 1

    # We no longer always check if the resources are extended at the experiment authority
    if must_check_expauth_extend:
        resources_to_check_on_expauth = resource_ids or [r.id for r in refreshed_resources]
        if len(refreshed_resources) != _wait_check_expauth_resources(
            ctx, exp, new_expires_at, resources_to_check_on_expauth
        ):
            has_errors += 1

    with ErrorDetailsHandler() as error_details:
        error_details.must_store_failure = has_errors > 0
        error_details.add_object("exp", exp)
        error_details.add_object("refreshed_experiment", refreshed_experiment)
        error_details.add_object("new_expires_at", new_expires_at)
        error_details.add_object("resource_ids", resource_ids)
        if has_errors > 0:
            _show_experiment_extend_check_fail(ctx, refreshed_experiment, new_expires_at, error_details)

        console.print("")
        _show_expauth_resources_overview(ctx, exp, new_expires_at, resource_ids, has_errors, error_details)

        if has_errors > 0:
            raise typer.Exit(1)


def _handle_extend_exception(
    ctx: BIContext, exp: Experiment, new_expires_at: datetime, e: ApiException, resource: ComputeResource | None
):
    # Typically 400 or 409
    error_body = ""
    if isinstance(e, ApiResponseError):
        error_body = e._httpx_status_error.response.text

    desc = "resources" if not resource else f"resource {resource.friendly_name!r}"

    if "Experiment expiration cannot be after project expiration".lower() in error_body.lower():
        err_console.print(
            f"[bold red]Could not extend {desc} on infrastructure {ctx.obj.infra_id}: "
            f"Experiment expiration cannot be after project expiration :x:"
        )
        _print_experiment_and_project_expiration_info(ctx, exp, new_expires_at)
    # elif "Resource expiration may not be after experiment expiration".lower() in (e.body or "").lower():
    #     err_console.print(
    #         f"[bold red]Could not extend resources on infrastructure {ctx.obj.infra_id}: "
    #         f"Experiment could not be renewed for long enough? :x:"
    #     )
    elif "Resource expiration may not be decreased".lower() in error_body.lower():
        err_console.print(
            f"[bold red]Could not extend {desc} on infrastructure {ctx.obj.infra_id}: "
            f"Resource expiration may not be decreased :x:"
        )
    else:
        with ErrorDetailsHandler() as error_details:
            error_details.print(f"[bold red]Could not extend {desc} on infrastructure {ctx.obj.infra_id} :x:")
            error_details.print(f"[red]{get_api_error_detail(e)}")
            error_details.add_object("desc", desc)
            error_details.add_object("error_body", error_body)
            error_details.add_cur_trace()
            # error_details.add_trace(e)


def _extend_all_resources_on_infra(ctx: BIContext, exp: Experiment, new_expires_at: datetime):
    try:
        tasks = ctx.obj.get_bi_resources_api_by_experiment(exp).update_all(new_expires_at=new_expires_at)
        task_results = follow_progress(
            ctx.obj.bi_tasks_api,
            [task.id for task in tasks],
            [
                f"Extending resource on {ctx.obj.infra_id}",
            ]
            if len(tasks) == 1
            else ["Extending resource" for task in tasks],
        )
        task_result_statuses = [t.status for t in task_results.values()]

        if set(task_result_statuses) != {"finished"}:
            with ErrorDetailsHandler() as error_details:
                error_details.print("[red][bold]Resources failed to extend :x:")
                error_details.add_object("task_results", list(task_results.values()))
                for task_result in task_results.values():
                    if task_result.failure_reason:
                        error_details.print(f"[red]Failure: {task_result.failure_reason.code.name}")
                        error_details.print(f"[red]         {task_result.failure_reason.message}")
                    logs = ctx.obj.bi_tasks_api.get_task_logs(task_result.id)
                    error_details.add_object(f"logs for task {task_result.id}", logs)
                    if logs and not task_result.failure_reason:
                        error_details.print(f"[red]{logs}")
                raise typer.Exit(1)

        console.print(
            f":recycle: Resources on infrastructure {ctx.obj.infra_id} "
            f"now expire on {friendly_datetime(new_expires_at)}."
        )
    except ApiException as e:
        _handle_extend_exception(ctx, exp, new_expires_at, e, None)
        raise typer.Exit(code=1) from None


def _extend_resources(ctx: BIContext, exp: Experiment, new_expires_at: datetime, resources: list[ComputeResource]):
    has_errors = False
    task_ids = []
    task_description_prefixes = []
    extended_resources = []

    # TODO: bvermeul, this currently allows shortening (which is not 'extend' and might run us into strange problems)
    for res in resources:
        try:
            task = ctx.obj.get_bi_resources_api_by_experiment(exp).update(res.id, new_expires_at=new_expires_at)
        except NotFoundException:  # noqa: PERF203
            err_console.print(f"[red]{res.friendly_name} ({res.id}) was not found :x:")
            has_errors = True
        except ApiException as e:  # noqa: PERF203
            _handle_extend_exception(ctx, exp, new_expires_at, e, res)
            has_errors = True
        else:
            task_ids.append(task.id)
            task_description_prefixes.append(f"Extending Resource {res.friendly_name}")
            extended_resources.append(res)

    task_results = follow_progress(
        ctx.obj.bi_tasks_api,
        task_ids,
        task_description_prefixes,
    )

    with ErrorDetailsHandler() as error_details:
        error_details.must_store_failure = False
        error_details.add_object("extended_resources", extended_resources)
        error_details.add_object("task_results", list(task_results.values()))

        successfully_extended_resources = []
        for resource, result in zip(extended_resources, task_results.values(), strict=True):
            if result.status != "finished":
                has_errors = True
                error_details.must_store_failure = True
                error_details.print(f"[red][bold]Resource {resource.friendly_name} failed to extend :x:")
                logs = ctx.obj.bi_tasks_api.get_task_logs(result.id)
                error_details.add_object(
                    f"logs for extend task for resource {resource.friendly_name} ({resource.id})", logs
                )
                error_details.print(f"[red]{logs}")
            successfully_extended_resources.append(resource)

        if has_errors:
            error_details.print("[red]Some resources could not be extended :x:")
            raise typer.Exit(1)

    console.print(
        f":recycle: The following resources at {ctx.obj.infra_id} now expire on {friendly_datetime(new_expires_at)}: "
        f"{','.join([res.friendly_name for res in successfully_extended_resources])}"
    )


@bi_app.command("extend", rich_help_panel="Commands")
@handle_some_exceptions
def extend_resource(
    ctx: BIContext,
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    new_expires_at: Annotated[
        datetime,
        typer.Option(
            "--duration",
            "-d",
            help="New lifetime of the resource. Relative durations are computed from current time.",
            parser=duration_or_datetime,
            callback=_validate_duration_option,
            show_default=False,
        ),
    ],
    resources: Annotated[list[str] | None, typer.Argument(help="Resource names or IDs.", show_default=False)] = None,
    must_check_expauth_extend: Annotated[
        bool,
        typer.Option(
            "--check-if-extended-at-expauth",
            help="Do an extra check to ensure the new expires_at is registered at the experiment authority.",
            show_default=False,
            rich_help_panel="Advanced Options",
        ),
    ] = False,
):
    """Extend the expiration date of one or more basic infrastructure resources (on a single infrastructure)."""
    # If no resource(s) are specified, then this will extend all resources in this experiment on the infrastructure
    # specified by --infra-id."""
    exp = bi_get_experiment(ctx, experiment)

    # The expire detection has some wiggle room, a bad local clock should not prevent extending just in time.
    expires_at_deadline = datetime.now(UTC) - timedelta(minutes=3)

    if exp.expires_at < expires_at_deadline:
        err_console.print(f"[bold red]The experiment expired at {friendly_datetime(exp.expires_at)} :x:")
        console.print("[grey]Expired experiments have no resources.")
        console.print("[grey]You can add new resources to the experiment, which will extend it automatically.")
        raise typer.Exit(1)

    current_project_id = ctx.obj.get_current_project_id()
    project = ctx.obj.api.project.get(current_project_id) if current_project_id else None
    if project and project.expires_at and project.expires_at < new_expires_at:
        err_console.print("[bold red]Cannot extend experiment/resource expiration beyond project expiration :x:")
        _print_experiment_and_project_expiration_info(ctx, exp, new_expires_at)
        max_extend = project.expires_at - timedelta(minutes=1)
        console.print(
            f"Maximum possible extend: --duration '{max_extend.astimezone(local_timezone).strftime('%Y-%m-%dT%H:%M')}'"
        )
        raise typer.Exit(1)

    resource_objects = [bi_get_resource(ctx, exp, r) for r in resources] if resources else None

    expired_resources: list[ComputeResource] = []
    inactive_resources: list[ComputeResource] = []
    active_resources: list[ComputeResource] = []

    if not resources:
        all_resources: Collection[ComputeResource] = _get_requested_bi_resources(
            ctx, exp, None, include_expired=False, include_terminated=False
        )
        has_no_resources = len(all_resources) == 0
        resources_to_extend = all_resources
    else:

        def is_active_status(status: ResourceState):
            return status in (
                ResourceState.IMAGING,
                ResourceState.BOOTING,
                ResourceState.INITIALIZING,
                ResourceState.STARTING,
                ResourceState.UP,
                ResourceState.REBOOTING,
            )

        for r in resource_objects or []:
            if not is_active_status(r.status):
                inactive_resources.append(r)
            elif r.expires_at and r.expires_at < expires_at_deadline:
                # this will probably not match anything, because expired resources will have an inactive status.
                expired_resources.append(r)
            else:
                active_resources.append(r)

        resources_to_extend = active_resources
        has_no_resources = len(active_resources) == 0

    if has_no_resources:
        err_console.print("[bold red]There are no active resources to extend :x:")
        raise typer.Exit(1)

    for r in inactive_resources:
        err_console.print(f"[yellow]Not extending resource {r.friendly_name} because it has status {r.status.name}")

    for r in expired_resources:
        err_console.print(
            f"[yellow]Not extending resource {r.friendly_name} because it has expired (status: {r.status.name})"
        )

    # if experiment expiration is shorter than requested duration, extend the experiment
    if new_expires_at > exp.expires_at:
        err_console.print(
            f"Extending Experiment container from {friendly_datetime(exp.expires_at)} "
            f"to {friendly_datetime(new_expires_at)}..."
        )
        ctx.obj.api.experiment.update(
            exp.id,
            exp.project_id,
            expires_at=new_expires_at,
        )

    if not resources:
        # extend all resources if none are specified
        err_console.print(
            f"Extending the resources only on infrastructure {ctx.obj.infra_id}. "
            f"Use slices bi experiment list-resources to verify other infrastructures."
        )
        _extend_all_resources_on_infra(ctx, exp, new_expires_at)
        _check_extend_resources(ctx, exp, new_expires_at, [], must_check_expauth_extend=must_check_expauth_extend)
    else:
        # extend only the specified resources
        _extend_resources(ctx, exp, new_expires_at, list(resources_to_extend))
        _check_extend_resources(
            ctx,
            exp,
            new_expires_at,
            [r.id for r in resources_to_extend],
            must_check_expauth_extend=must_check_expauth_extend,
        )
    raise typer.Exit(0)


class ProxyOption(Enum):
    """Proxy Options."""

    off = "off"
    on = "on"
    auto = "auto"


class ShowOption(Enum):
    """Show options."""

    nothing = "nothing"
    json = "json"
    command = "command"
    ansible = "ansible"
    ssh_config = "ssh_config"
    server_host = "server_host"
    proxy_host = "proxy_host"
    server_pubkey_openssh = "server_pubkey_openssh"
    proxy_pubkey_openssh = "proxy_pubkey_openssh"


DEFAULT_SSH_PORT = 22


def _resource_to_ansible(compute_resource: ComputeResource, ssh_login: SSHInfo, proxy: bool):
    """Convert a resource to an Ansible inventory entry."""
    if proxy and ssh_login.jump_proxy:
        jump_proxies = _unnest_jump_proxies(ssh_login.jump_proxy)
        jump_proxy_cli_args, _ = _make_jump_proxy_cli_args_and_config(jump_proxies)
        jump_proxy_cli_args_str = " ".join(jump_proxy_cli_args)
        ansible_ssh_common_args = f" ansible_ssh_common_args='{jump_proxy_cli_args_str}'"
    else:
        ansible_ssh_common_args = ""

    return (
        f"{compute_resource.friendly_name} "
        f"ansible_ssh_port={ssh_login.port} "
        f"ansible_ssh_host={ssh_login.host} "
        f"ansible_ssh_user={ssh_login.username} {ansible_ssh_common_args}"
    )


def _check_resource_available_for_action(compute_resource: ComputeResource):
    now = datetime.now(UTC)
    if compute_resource.status == ResourceState.DELETED:
        with ErrorDetailsHandler() as error_details:
            error_details.add_object("compute_resource", compute_resource)
            if not compute_resource.terminated_at:
                # Edge case that should not occur unless backend has bugs.
                error_details.print(
                    f"[red]:recycle: Resource {compute_resource.friendly_name} ({compute_resource.id}) was destroyed"
                )
                raise typer.Exit(1)

            humanized_delta = humanize.precisedelta(
                compute_resource.terminated_at - now,
                minimum_unit="minutes",
                format="%0.0f",
            )
            if compute_resource.expires_at and compute_resource.expires_at <= compute_resource.terminated_at:
                # Resource was deleted because it expired (edge case: or deleted manually exactly at expire time)
                humanized_delta = humanize.precisedelta(
                    compute_resource.expires_at - now,
                    minimum_unit="minutes",
                    format="%0.0f",
                )
                error_details.print(
                    f"[red]:clock10: Resource {compute_resource.friendly_name} "
                    f"has expired on {friendly_datetime(compute_resource.expires_at)} (= {humanized_delta} ago)."
                )
            else:
                # Resource has been deleted manually before expire time
                error_details.print(
                    f"[red]:recycle: Resource {compute_resource.friendly_name} ({compute_resource.id}) "
                    f"was destroyed on {friendly_datetime(compute_resource.terminated_at)} (= {humanized_delta} ago)."
                )
            raise typer.Exit(1)


def _ssh_common(
    ctx: BIContext,
    resource: str,
    experiment: str,
    proxy: ProxyOption,
) -> tuple[Experiment, ComputeResource, SSHInfo, bool, list[str], str, str]:
    exp = bi_get_experiment(ctx, experiment)
    compute_resource = bi_get_resource(ctx, exp, resource)
    _check_resource_available_for_action(compute_resource)

    ssh_login, can_connect = bi_get_best_ssh_login(compute_resource, with_proxy=proxy == ProxyOption.on)
    if not ssh_login:
        err_console.print("The resource has no SSH logins")
        raise typer.Exit(code=1)

    destination = f"{ssh_login.username}@{ssh_login.host}"

    if proxy == ProxyOption.on and not ssh_login.jump_proxy:
        err_console.print("[red]Requested to use the proxy, but the resource has no jump proxy available.")
        err_console.print("Reverting to direct connection.")

    if proxy == ProxyOption.auto:
        proxy = (
            ProxyOption.off
            if ((compute_resource.public_ipv4 and compute_resource.public_ipv4 in ssh_login.host) or can_connect)
            else ProxyOption.on
        )

    ssh_command_options: list[str] = []
    ssh_config_jump_proxy = ""
    if ssh_login.port != DEFAULT_SSH_PORT:
        ssh_command_options.append(f"-p {ssh_login.port}")

    jump_proxies = []
    if proxy == ProxyOption.on and ssh_login.jump_proxy:
        jump_proxies = _unnest_jump_proxies(ssh_login.jump_proxy)
        jump_proxy_cli_args, ssh_config_jump_proxy = _make_jump_proxy_cli_args_and_config(jump_proxies)
        ssh_command_options.extend(jump_proxy_cli_args)

    if ssh_login.host_keys and compute_resource.status in (
        ResourceState.IMAGING,
        ResourceState.BOOTING,
        ResourceState.INITIALIZING,
        ResourceState.STARTING,
        ResourceState.UP,
    ):
        SSHKnownHostsManager().cleanup_expired()
        SSHKnownHostsManager().add_login(compute_resource, ssh_login)

        for jump_proxy in jump_proxies:
            SSHKnownHostsManager().add_login(compute_resource, jump_proxy)

    return exp, compute_resource, ssh_login, can_connect, ssh_command_options, destination, ssh_config_jump_proxy


def make_ssh_command(
    ctx: BIContext,
    resource_id: str,
    experiment_id: str,
    proxy: ProxyOption = ProxyOption.auto,
) -> list[str]:
    """Make the command to connect to the ssh."""
    exp, compute_resource, ssh_login, can_connect, ssh_command_options, destination, ssh_config_jump_proxy = (
        _ssh_common(ctx, resource_id, experiment_id, proxy)
    )

    return [
        get_bi_settings().ssh_exe,
        *get_bi_settings().extra_ssh_options,
        *ssh_command_options,
        destination,
    ]


@bi_app.command(
    "ssh", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, rich_help_panel="Commands"
)
@handle_some_exceptions
def ssh_to_resource(
    ctx: BIContext,
    resource: Annotated[str, typer.Argument(help="Name of the resource.", show_default=False)],
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    exec: Annotated[
        bool,
        typer.Option(
            "--exec/--no-exec",
            help="Exec SSH to connect to the resource. (Default: --exec)",
            show_default=False,
        ),
    ] = True,
    show: Annotated[
        ShowOption,
        typer.Option(
            help="What to show: Nothing, SSH Command, Ansible inventory line, "
            "lines for ~/.ssh/config, or the server public key",
            show_default=False,
        ),
    ] = "command",  # pyright: ignore [reportArgumentType]  weird but this works
    proxy: Annotated[ProxyOption, typer.Option(help="Use an SSH jump proxy.", case_sensitive=False)] = "auto",  # pyright: ignore [reportArgumentType]  weird but this works
):
    """Log in to a resource using SSH. Alternatively, show info, such as the login command.

    There are 3 ways to pass extra arguments to ssh:

    - Any extra CLI arguments are always passed directly to ssh.

    - The env var SLICES_BI_EXTRA_SSH_OPTIONS can be used to specify extra options to ssh.

    - When SLICES_BI_EXTRA_SSH_OPTIONS is not present, the file ~/.slices/bi_settings.toml is checked for any
    extra options.
    """
    if show in (ShowOption.proxy_host, ShowOption.proxy_pubkey_openssh) and proxy == ProxyOption.auto:
        # if proxy info is requested, turn on proxy
        proxy = ProxyOption.on

    exp, compute_resource, ssh_login, can_connect, ssh_command_options, destination, ssh_config_jump_proxy = (
        _ssh_common(ctx, resource, experiment, proxy)
    )

    command = [
        get_bi_settings().ssh_exe,
        *get_bi_settings().extra_ssh_options,
        *ssh_command_options,
        destination,
        *ctx.args,
    ]

    command_line = " ".join(command)

    do_exec_ssh = exec and show in [ShowOption.command, ShowOption.nothing]

    if show == ShowOption.command:
        if do_exec_ssh:
            console.print(f"[bold blue]{command_line}")
        else:
            typer.echo(command_line)  # output without any markup
    elif show == ShowOption.ansible:
        # output without any markup
        typer.echo(_resource_to_ansible(compute_resource, ssh_login, proxy == ProxyOption.on))
    elif show == ShowOption.json:
        obj = {
            "host": ssh_login.host,
            "port": ssh_login.port,
            "username": ssh_login.username,
        }
        if ssh_login.jump_proxy:
            obj["jump_proxy"] = {
                "host": ssh_login.jump_proxy.host,
                "port": ssh_login.jump_proxy.port,
                "username": ssh_login.jump_proxy.username,
            }
        typer.echo(json.dumps(obj, indent=3))

    elif show == ShowOption.ssh_config:
        # output without any markup
        typer.echo(f"""Host={ssh_login.host}
   HostName {ssh_login.host}
   Port {ssh_login.port}
   User {ssh_login.username}
   PreferredAuthentications publickey
   IdentitiesOnly yes
   {ssh_config_jump_proxy}""")
    elif show == ShowOption.server_host:
        typer.echo(ssh_login.host)
    elif show == ShowOption.proxy_host:
        if ssh_login.jump_proxy:
            typer.echo(ssh_login.jump_proxy.host)
        else:
            err_console.print("No jump proxy")
    elif show == ShowOption.server_pubkey_openssh:
        if ssh_login.host_keys:
            for host_key in ssh_login.host_keys:
                typer.echo(f"{host_key.strip()}")
        else:
            err_console.print("No hostkeys in SSH info")
    elif show == ShowOption.proxy_pubkey_openssh:
        if ssh_login.jump_proxy:
            if ssh_login.jump_proxy.host_keys:
                for host_key in ssh_login.jump_proxy.host_keys:
                    typer.echo(f"{host_key.strip()}")
            else:
                err_console.print("No hostkeys in proxy SSH info")
        else:
            err_console.print("No jump proxy")

    if do_exec_ssh:
        signal.signal(signal.SIGINT, signal.SIG_DFL)
        os.setpgrp()
        os.execvp(command[0], command)  # noqa: S606


RESOURCE_SCP_PATTERN = re.compile(r"^([a-zA-Z0-9_-]+):")


def _extract_scp_resource(args: list[str]) -> str:
    res = []
    for arg in args:
        m = RESOURCE_SCP_PATTERN.match(arg)
        if m:
            res.append(m.group(1))
    if len(res) == 0:
        err_console.print("[red]No resources found in scp arguments :x:")
        raise typer.Exit(code=1)
    if len(res) > 1:
        err_console.print("[red]Multiple resources found in scp arguments :x:")
        raise typer.Exit(code=1)
    return res[0]


def _modify_scp_args_with_resource(args: list[str], destination: str) -> list[str]:
    res = []
    for arg in args:
        m = RESOURCE_SCP_PATTERN.match(arg)
        if m:
            res.append(RESOURCE_SCP_PATTERN.sub(destination + ":", arg, count=1))
        else:
            res.append(arg)
    return res


@bi_app.command(
    "scp", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, rich_help_panel="Commands"
)
@handle_some_exceptions
def scp_to_resource(
    ctx: BIContext,
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    exec: Annotated[
        bool,
        typer.Option(
            "--exec/--no-exec",
            help="Exec SCP to connect to the resource. (Default: --exec)",
            show_default=False,
        ),
    ] = True,
    show_command: Annotated[
        bool,
        typer.Option(
            "--show-command/--no-show-command",
            help="Show command. (Default: --show-command)",
            show_default=False,
        ),
    ] = True,
    proxy: Annotated[ProxyOption, typer.Option(help="Use an SSH jump proxy.", case_sensitive=False)] = "auto",  # pyright: ignore [reportArgumentType]  weird but this works
):
    """Scp to/from a resource.

    The scp tool is part of openssh and copies files using ssh (the scp tool actually uses sftp).

    Extra arguments are passed to scp, after replacing friendly names of resources by the remote user@host.
    So for example, the argument "client0:/etc/issue" might be replaced by "user@example.com:/etc/issue"
    if there is a resource with friendly_name "client0" and that resource has an SSH login user@example.com.

    If you have a resource "client0" and want to target a local file with "client0" in the name,
    use an absolute or relative path. For example: "./client0.txt". The same applies for files with ":" in the name.
    For example: "./file:part1.txt" or "client0:./file:part1.txt"

    There are 2 additional ways to pass extra options to scp:

    - The env var SLICES_BI_EXTRA_SCP_OPTIONS can be used to specify extra options to scp.

    - When SLICES_BI_EXTRA_SCP_OPTIONS is not present, the file ~/.slices/bi_settings.toml is checked for any
    extra options.
    """
    resource = _extract_scp_resource(ctx.args)

    exp, compute_resource, ssh_login, can_connect, ssh_command_options, destination, ssh_config_jump_proxy = (
        _ssh_common(ctx, resource, experiment, proxy)
    )

    modified_args = _modify_scp_args_with_resource(ctx.args, destination)
    command = [
        get_bi_settings().scp_exe,
        *get_bi_settings().extra_scp_options,
        *ssh_command_options,
        *modified_args,
    ]

    command_line = " ".join(command)

    if show_command:
        if exec:
            console.print(f"[bold blue]{command_line}")
        else:
            typer.echo(command_line)  # output without any markup

    if exec:
        signal.signal(signal.SIGINT, signal.SIG_DFL)
        os.setpgrp()
        os.execvp(command[0], command)  # noqa: S606


@bi_app.command(
    "sftp", context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, rich_help_panel="Commands"
)
@handle_some_exceptions
def sftp_to_resource(
    ctx: BIContext,
    resource: Annotated[str, typer.Argument(help="Name of the resource.", show_default=False)],
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    exec: Annotated[
        bool,
        typer.Option(
            "--exec/--no-exec",
            help="Exec SFTP to connect to the resource. (Default: --exec)",
            show_default=False,
        ),
    ] = True,
    show_command: Annotated[
        bool,
        typer.Option(
            "--show-command/--no-show-command",
            help="Show command. (Default: --show-command)",
            show_default=False,
        ),
    ] = True,
    proxy: Annotated[ProxyOption, typer.Option(help="Use an SSH jump proxy.", case_sensitive=False)] = "auto",  # pyright: ignore [reportArgumentType]  weird but this works
):
    """Sftp to a resource.

    The sftp tool is part of openssh and provides an interactive sftp shell.
    Extra arguments are passed directly to sftp.

    There are 2 additional ways to pass extra options to sftp:

    - The env var SLICES_BI_EXTRA_SFTP_OPTIONS can be used to specify extra options to sftp.

    - When SLICES_BI_EXTRA_SFTP_OPTIONS is not present, the file ~/.slices/bi_settings.toml is checked for any
    extra options.
    """
    exp, compute_resource, ssh_login, can_connect, ssh_command_options, destination, ssh_config_jump_proxy = (
        _ssh_common(ctx, resource, experiment, proxy)
    )

    command = [
        get_bi_settings().sftp_exe,
        *get_bi_settings().extra_sftp_options,
        *ssh_command_options,
        destination,
        *ctx.args,
    ]

    command_line = " ".join(command)

    if show_command:
        if exec:
            console.print(f"[bold blue]{command_line}")
        else:
            typer.echo(command_line)  # output without any markup

    if exec:
        signal.signal(signal.SIGINT, signal.SIG_DFL)
        os.setpgrp()
        os.execvp(command[0], command)  # noqa: S606


@bi_app.command("add_authorized_keys", rich_help_panel="Commands")
@handle_some_exceptions
def add_authorized_keys(
    ctx: BIContext,
    resource: Annotated[str, typer.Argument(help="Name of the resource.")],
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    member_name: Annotated[
        str | None,
        typer.Option(
            help="Name of the member this key belongs to. Added as comment to the SSH key, to easily identify it.",
            show_default=False,
        ),
    ],
    pubkeys: Annotated[list[str], typer.Argument(help="One or more public keys in openssh format.")],
):
    """Add public SSH keys to a host's ~/.ssh/authorized_keys file.

    This calls openssh to edit the file.

    It also calls the backend to register the keys there. This will add the keys to the ssh jump proxy if needed.
    """

    exp = bi_get_experiment(ctx, experiment)
    r = bi_get_resource(ctx, exp, resource)

    # We need to make sure the keys are added to any ssh jump proxy.
    # A BI backend endpoint is the only way to do this.
    try:
        # This is experimental and not supported by all sites yet.
        # This might still be changed or removed.
        # So if this fails, we continue with a warning.
        ctx.obj.get_bi_resources_api_by_experiment(exp).update_ssh_authorized_keys(
            r.id, ssh_authorized_keys_to_add=pubkeys, ssh_authorized_keys_to_remove=[]
        )
    except:  # noqa: E722 replace with decent handling when no longer experimental
        err_console.print(
            "[red]Failed to add SSH keys to the resource in the BI backend. "
            "The keys might not be present on the ssh jump proxy."
        )

    # Now we add the keys to the actual resource itself, using SSH.
    base_ssh_command = make_ssh_command(ctx, r.id, exp.id, ProxyOption.auto)

    full_command = create_add_ssh_pubkey_command(base_ssh_command, pubkeys, member_name)

    command_error = None
    try:
        with Popen(  # noqa: S603
            ["/usr/bin/sh", "-s"],
            stdout=PIPE,
            stderr=STDOUT,
            stdin=PIPE,
            universal_newlines=True,
        ) as proc:
            try:
                proc.communicate(input=full_command, timeout=58)
            except TimeoutExpired:
                command_error = "Timeout trying to modify ~/.ssh/authorized_keys."
                # console.print("[bold red]Timeout trying to modify ~/.ssh/authorized_keys.")
                proc.kill()
            else:
                res_code = proc.wait()
                if res_code != 0:
                    command_error = f"Failed to modify ~/.ssh/authorized_keys. Return value is {res_code}"
                    # console.print("[bold red]Failed to modify ~/.ssh/authorized_keys.")
    except:
        command_error = "Failure executing command."
        raise
    finally:
        console.print(
            Panel(
                full_command.strip(),
                highlight=False,
                expand=False,
                style="red" if command_error else "blue",
                title_align="left",
                title=f"[bold blue]{'Failed to add' if command_error else 'Added'} SSH keys "
                f"of {member_name!r} to {r.friendly_name!r}. "
                f"Command used:",
                subtitle_align="left",
                subtitle=command_error or None,
            )
        )


@bi_app.command("console", rich_help_panel="Commands")
@handle_some_exceptions
def console_to_resource(
    ctx: BIContext,
    resource: Annotated[str, typer.Argument(help="Name of the resource.", show_default=False)],
    experiment: Annotated[
        str,
        typer.Option(..., help="Experiment name or ID.", prompt=True, show_default=False, envvar="SLICES_EXPERIMENT"),
    ],
    show_only: Annotated[
        bool, typer.Option("--show-only", help="Do not try to open console URL, only show it.", show_default=False)
    ] = False,
):
    """Open a browser with a console to a resource. Also print the link that is opened."""
    exp = bi_get_experiment(ctx, experiment)
    compute_resource = bi_get_resource(ctx, exp, resource)
    _check_resource_available_for_action(compute_resource)

    if not compute_resource.console_url:
        err_console.print("The resource has no remote console")
        raise typer.Exit(code=1)

    login: ConsoleLoginDetails | None
    try:
        login: ConsoleLoginDetails | None = (
            ctx.obj.api.for_experiment(exp.id, exp.project_id)
            .bi(ctx.obj.infra_id)
            .resources.get_console_login_details(
                compute_resource.id,
            )
        )
    except NotFoundException:
        login = None

    if login:
        console.print(f"Console user: {login.username}")
        console.print(f"Console pass: {login.password}")
    console.print(f"Console URL: [bold blue]{compute_resource.console_url}")

    if not show_only:
        webbrowser.open(compute_resource.console_url, new=1)


class ExtendedComputeResourceCreate(ComputeResourceCreate, forbid_unknown_fields=True):
    """Extended ComputeResourceCreate."""

    userdata_file: str | None = None

    infra_id: Annotated[
        str | None,
        Meta(description="The infrastructure on which the resource must be provisioned"),
    ] = None


class Request(Struct, forbid_unknown_fields=True):
    """Compute Resources Request."""

    resources: list[ComputeResourceCreate]
    links: list[BareMetalLinkCreate] = field(default_factory=list)


class ExtendedRequest(Struct, forbid_unknown_fields=True):
    """Extended Compute Resources Request."""

    resources: list[ExtendedComputeResourceCreate]
    links: list[BareMetalLinkCreate] = field(default_factory=list)


def _process_extended_resource(
    ctx: BIContext,
    resource: ExtendedComputeResourceCreate,
    file_path: str,
    default_infra_id: str | None,
) -> ComputeResourceCreate:
    if resource.userdata_file:
        if resource.userdata:
            err_console.print("[red]Only one of userdata or userdata_file can be specified.")
            raise typer.Exit(code=1)

        userdata_file_path = Path(Path(file_path).parent, resource.userdata_file)

        if not userdata_file_path.exists():
            err_console.print(f"[red]Userdata file {resource.userdata_file} does not exist.")
            raise typer.Exit(code=1)

        with userdata_file_path.open("rt") as f:
            userdata = f.read()
    else:
        userdata = resource.userdata

    resource_infra_id = resource.infra_id or default_infra_id

    if resource.flavor_id:
        err_console.print("[red]flavor_id is deprecated. Use flavor instead.")
        raise typer.Exit(code=1)

    flavor = resource.flavor
    # We no longer resolve flavors. The backends will do that themselves if needed.
    # if not flavor and resource_infra_id:
    #     flavor = _get_default_flavor(ctx, resource_infra_id)
    # if flavor and resource_infra_id and not is_type_id(flavor, type="flavor"):
    #     flavor = _find_flavor_id(ctx, flavor, infra_id=resource_infra_id)

    if not flavor:
        err_console.print("[red]Flavor is required.")
        raise typer.Exit(code=1)

    if resource.disk_image_id:
        err_console.print("[red]disk_image_id is deprecated. Use disk_image instead.")
        raise typer.Exit(code=1)

    disk_image = resource.disk_image
    if not disk_image:
        disk_image = "default"
    # We no longer resolve disk images. The backends will do that themselves if needed.
    # if disk_image and resource_infra_id and not is_type_id(disk_image, type="image"):
    #     disk_image = _find_disk_image_id(ctx, disk_image, infra_id=resource_infra_id)

    if not disk_image:
        err_console.print("[red]Disk image is required.")
        raise typer.Exit(code=1)

    if resource_infra_id:
        if resource_infra_id == "bi-orchestrator":
            err_console.print(
                f"[red]You cannot use an orchestrator ({resource_infra_id!r}) as infra_id for a resource."
            )
            raise typer.Exit(code=1)
        if resource_infra_id not in ctx.obj.api.bi_config.bi_infrastructures:
            err_console.print(f"[red]Infrastructure {resource_infra_id} is unknown.")
            raise typer.Exit(code=1)
        if is_type_id(disk_image) and not disk_image.startswith(f"image_{resource_infra_id}_"):
            err_console.print(
                f"[red]Disk image {resource.disk_image} does not belong to infrastructure {resource_infra_id}."
            )
            raise typer.Exit(code=1)
        if is_type_id(flavor) and not flavor.startswith(f"flavor_{resource_infra_id}_"):
            err_console.print(f"[red]Flavor {resource.flavor} does not belong to infrastructure {resource_infra_id}.")
            raise typer.Exit(code=1)

    return ComputeResourceCreate(
        infra_id=resource_infra_id,
        friendly_name=resource.friendly_name,
        disk_image=resource.disk_image,
        flavor=resource.flavor,
        network_interfaces=resource.network_interfaces,
        userdata=userdata,
        machine_id=resource.machine_id,
    )


def _resolve_machine_ids(ctx: BIContext, extended_request: ExtendedRequest):
    # Check if all machine_id can be resolved
    resources_with_unresolvable_machine_ids = [
        r
        for r in extended_request.resources
        if r.machine_id and not is_type_id(r.machine_id, type="m") and not r.infra_id
    ]
    if resources_with_unresolvable_machine_ids:
        err_console.print(
            f"[red] :x: Some resources have a name as machine_id but no infra_id. This is not supported. "
            f"Resources: {', '.join([r.friendly_name for r in resources_with_unresolvable_machine_ids])} "
        )
        raise typer.Exit(code=1)

    # Resolve machine_id to ID
    has_unresolved_machine_ids = (
        len([r for r in extended_request.resources if r.machine_id and not is_type_id(r.machine_id, type="m")]) > 0
    )
    if has_unresolved_machine_ids:
        machines_by_infra_id = {}
        for r in extended_request.resources:
            if r.machine_id and not is_type_id(r.machine_id, type="m") and r.infra_id:
                machines = machines_by_infra_id.get(r.infra_id)
                if not machines:
                    machines = ctx.obj.api.for_project(ctx.obj.require_project_id()).bi(r.infra_id).machine.list()
                    machines_by_infra_id[r.infra_id] = machines
                machine_name = r.machine_id
                machine_id = None
                for machine in machines:
                    if not machine_id and machine.name == machine_name:
                        machine_id = machine.id
                if machine_id:
                    r.machine_id = machine_id
                else:
                    err_console.print(
                        f"[red]Could not find machine with name '{machine_name}' in infra '{r.infra_id}' :x:"
                    )
                    raise typer.Exit(code=1)


def _apply_validation_aliases(request_dict: Any) -> Any:
    """Apply validation aliases manually.

    msgspec does not support validation aliases (yet?).
    This code manually applies the aliases before msgspec sees them.
    """
    if not isinstance(request_dict, dict):
        return request_dict

    resources = request_dict.get("resources", [])
    for resource in resources:
        if not isinstance(resource, dict):
            continue
        if resource.get("infra_id"):
            continue
        if resource.get("infra"):
            resource["infra_id"] = resource.get("infra")
            del resource["infra"]
            continue
        if resource.get("site_id"):
            resource["infra_id"] = resource.get("site_id")
            del resource["site_id"]
            continue

    for resource in resources:
        if not isinstance(resource, dict):
            continue
        if resource.get("disk_image"):
            continue
        if resource.get("image"):
            resource["disk_image"] = resource.get("image")
            del resource["image"]
            continue

    return request_dict


def _read_and_process_request_file(ctx: BIContext, file: typer.FileText) -> tuple[str | None, Request]:
    """Return None if orchestrator should be used. Infra_id otherwise."""
    try:
        # We support JSON5 instead of plain JSON, which allows trailing comma's, comments, etc.
        # See https://json5.org/
        request_dict = pyjson5.load(file)  # pyright: ignore [reportArgumentType] FileText is compatible enough it seems
    except Json5DecoderException as e:
        err_console.print(f"[red]Errors parsing JSON5 in request file: {e}")
        raise typer.Exit(code=1) from e
    except OSError as e:
        err_console.print(f"[red]Error reading request file: {type(e)}: {e}")
        raise typer.Exit(code=1) from e

    request_dict = _apply_validation_aliases(request_dict)
    request_json = json_encode(request_dict)

    try:
        extended_request = json_decode(request_json, type=ExtendedRequest)
    except ValidationError as e:
        err_console.print("[red]Errors in request file:")
        err_console.print(f"[red]{str(e)}")
        raise typer.Exit(code=1) from e

    # if --infra-id is set (to non-orchestrator), we have a default infra_id to use if resources do not specify one.
    default_infra_id = ctx.obj.infra_id if ctx.obj.infra_id and ctx.obj.infra_id != "bi-orchestrator" else None

    # Get a list of all unique infra_ids used. This can include None.
    #    (None occurs only when there is no default_infra_id)
    infra_ids = {(r.infra_id) if r.infra_id else default_infra_id for r in extended_request.resources}

    # See if we need to use the orchestrator (= there is no knowable infra_id, or there is more than one.)
    use_orchestrator = len(infra_ids) != 1 or (None in infra_ids and default_infra_id is None)

    # If we don't need to use the orchestrator, we can contact a single infrastructure.
    # Note that this is not always the infra in --infra-id!
    #      If all infra_id in the file are the same, but not the same as --infra-id, we ignore --infra-id
    infra_id = infra_ids.pop() if not use_orchestrator else None

    _resolve_machine_ids(ctx, extended_request)

    return infra_id, Request(
        resources=[
            _process_extended_resource(
                ctx,
                er,
                file_path=file.name,
                default_infra_id=default_infra_id,
            )
            for er in extended_request.resources
        ],
        links=extended_request.links,
    )
