"""Experiment CLI."""

import json
import time
from collections.abc import Collection
from datetime import datetime
from enum import Enum
from typing import Annotated

import humanize
import jwt
import typer
import yaml
from msgspec.json import encode as json_encode
from msgspec.yaml import encode as yaml_encode
from pytz import UTC
from requests.exceptions import HTTPError
from rich.progress import Progress, SpinnerColumn, TaskID
from rich.table import Table
from slices_clientlib_core.base.exceptions import ApiException, NotFoundException
from slices_clientlib_core.experiment import Experiment, ExperimentResource, ResourceState

from slices_cli_core import console, err_console
from slices_cli_core.core.context import Context
from slices_cli_core.util.errors import get_api_error_detail
from slices_cli_core.util.time import (
    STR_TO_TIMEDELTA_HELP,
    duration_or_datetime,
    friendly_datetime,
    str_to_timedelta,
    validate_timedelta_str,
)
from slices_cli_core.util.type_id import is_experiment_id, is_project_id

from .experiment_member import experiment_member_app

experiment_app = typer.Typer(short_help="Experiments", no_args_is_help=True)
experiment_app.add_typer(experiment_member_app, name="member", rich_help_panel="Commands")


@experiment_app.command(name="create", rich_help_panel="Commands")
def create_experiment(
    ctx: Context,
    name: Annotated[str, typer.Argument(help="Name of the experiment.", envvar="SLICES_EXPERIMENT")],
    expires_at: Annotated[
        datetime,
        typer.Option(
            "--duration",
            "-d",
            help="Lifetime of the experiment. Can be extended later on.",
            parser=duration_or_datetime,
        ),
    ] = "24h",  # type: ignore
    description: Annotated[str | None, typer.Option("--description", "--desc", "-D")] = None,
    project_id: Annotated[str | None, typer.Option("--project-id", "-p", hidden=True)] = None,
):
    """Create a new experiment."""

    if project_id:
        if not is_project_id(project_id):
            project = ctx.obj.api.get_project_by_name(project_id)

            if not project:
                err_console.print(f"[red]Could not find project with name {project_id} :magnifying_glass_tilted_left:")
                raise typer.Exit(1)

            project_id = project.id

        project_token = ctx.obj.api.get_project_jwt(project_id)
    else:
        project_token = ctx.obj.get_current_project_token()
        project_id = ctx.obj.get_current_project_id()

        if not project_token or not project_id:
            err_console.print(
                "[bold red]No default project set. "
                "Please specify a default project with `slices project use <name>`, or pass a project id. :x:"
            )
            raise typer.Exit(1)

    user_project_id_token = ctx.obj.api.get_id_with_project_jwt(
        ctx.obj.api.central_config.central_hub_services.experiment_authority.audience, project_id
    )
    try:
        experiment = ctx.obj.api.experiment.create(
            friendly_name=name,
            description=description,
            expires_at=expires_at,
            user_project_id_token=user_project_id_token,
        )

    except ApiException as err:
        err_console.print("[bold red]Could not create experiment :x:")
        err_console.print(f"[red]{get_api_error_detail(err)}")
        raise typer.Exit(1) from err

    if not console.is_terminal:
        console.print(experiment.id)
        return

    console.print(
        f"[green]:sparkles: Created experiment [bold]{experiment.friendly_name}[/bold] "
        f"that expires on [bold]{friendly_datetime(experiment.expires_at)}[/]",
    )
    console.print(f"Experiment ID: {experiment.id}")


class ListFormat(Enum):
    """List format options."""

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


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

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


def _print_experiments_table(  # noqa: C901
    ctx: Context,
    project_id: str | None,
    experiments: Collection[Experiment],
    include_archived: bool,
    show_ids: bool,
):
    project_id_to_name_map = {}
    projects = ctx.obj.api.project.list()
    if project_id is None:
        project_id_to_name_map = {project.id: project.name for project in projects}

    columns = ["Name", "Description", "Created at", "Expires at"]
    if include_archived:
        columns.append("Archived")

    if project_id is None:
        columns.insert(1, "Project")
    if show_ids:
        columns.insert(0, "ID")

    title = "Experiments"
    if project_id:
        project = ctx.obj.api.project.get(project_id)
        title = f"Experiments in project [bold]{project.name}[/]"

    table = Table(*columns, title=title)

    for exp in experiments:
        exp_expires_at = friendly_datetime(exp.expires_at)
        if exp.expires_at < datetime.now(UTC):
            exp_expires_at = f"[red]{exp_expires_at}"

        row = [
            exp.friendly_name,
            exp.description,
            friendly_datetime(exp.created_at),
            exp_expires_at,
        ]
        if include_archived:
            row.append("Yes" if exp.archived else "No")
        if not project_id:
            row.insert(1, project_id_to_name_map.get(exp.project_id, exp.project_id))

        if show_ids:
            row.insert(0, exp.id)

        table.add_row(*row, style="red" if exp.archived else "")

    console.print(table)


@experiment_app.command(name="list", rich_help_panel="Commands")
def list_experiments(
    ctx: Context,
    include_expired: Annotated[
        bool | None, typer.Option("--include-expired", "-e", help="Also show expired experiments.")
    ] = False,
    include_archived: Annotated[
        bool | None, typer.Option("--include-archived", "-A", help="Also show archived experiments.")
    ] = False,
    all: Annotated[bool | None, typer.Option("--all", "-a", help="Show all experiments.")] = None,
    all_projects: Annotated[
        bool | None, typer.Option("--all-projects", "-P", help="Show experiments from all your projects.")
    ] = None,
    show_ids: Annotated[bool, typer.Option("--show-ids", "-I", help="Show internal IDs.")] = False,
    name: Annotated[str | None, typer.Option("--name", "-n", help="Name of the experiment to list.")] = None,
    format: Annotated[ListFormat, typer.Option("--format", "-f")] = "text",  # type: ignore
    sort: Annotated[ListExperimentSort, typer.Option("--sort", "-s", help="Field to sort by.")] = "created",  # type: ignore
):
    """List experiments."""

    if all is True:
        include_expired = True
        include_archived = True

    project_id = ctx.obj.get_current_project_id()
    if all_projects:
        project_id = None

    experiments = ctx.obj.api.experiment.list(
        include_expired=include_expired,
        include_archived=include_archived,
        project_id=project_id,
        friendly_name=name,
    )

    if sort == ListExperimentSort.name:
        experiments = sorted(experiments, key=lambda exp: exp.friendly_name)
    elif sort == ListExperimentSort.created:
        experiments = sorted(experiments, key=lambda exp: exp.created_at)
    elif sort == ListExperimentSort.expires:
        experiments = sorted(experiments, key=lambda exp: exp.expires_at)

    if format == ListFormat.json:
        console.print_json(json_encode(list(experiments)).decode())
        return
    if format == ListFormat.yaml:
        console.print(yaml_encode(list(experiments)).decode())
        return

    _print_experiments_table(ctx, project_id, experiments, include_archived or False, show_ids)


def _get_experiment(ctx: Context, experiment_id_or_name: str):
    if is_experiment_id(experiment_id_or_name):
        return ctx.obj.api.experiment.get(experiment_id=experiment_id_or_name)

    experiments = ctx.obj.api.experiment.list(
        project_id=ctx.obj.require_project_id(),
        friendly_name=experiment_id_or_name,
    )

    try:
        return next(iter(experiments))
    except StopIteration:
        err_console.print(
            f"[red]Could not find experiment with name {experiment_id_or_name} :magnifying_glass_tilted_left:"
        )
        raise typer.Exit(1) from None


class Format(Enum):
    """Show experiment format Options."""

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


@experiment_app.command("show", rich_help_panel="Commands")
def show_experiment(
    ctx: Context,
    name: Annotated[str, typer.Argument(..., help="Name or ID of experiment to show.", envvar="SLICES_EXPERIMENT")],
    format: Annotated[Format, typer.Option("--format", "-f")] = "text",  # type: ignore
):
    """Show experiment info."""
    experiment = _get_experiment(ctx, name)

    if format == Format.json:
        console.print_json(json_encode(experiment).decode())
        return

    if format == Format.yaml:
        console.print(yaml_encode(experiment).decode())
        return

    project = None
    try:
        project = ctx.obj.api.project.get(experiment.project_id)
    except NotFoundException:
        err_console.print("[orange]Could not fetch project info :warning:")

    table = Table(box=None)
    table.add_column("Name", justify="right")
    table.add_column("Value")
    table.add_row("ID", experiment.id)
    table.add_row("Name", experiment.friendly_name)
    table.add_row("Project ID", experiment.project_id)
    table.add_row("Project name", project.name if project else "** Unknown **")
    table.add_row("Created on", friendly_datetime(experiment.created_at))
    table.add_row("Expires on", friendly_datetime(experiment.expires_at))
    table.add_row("Deleted", "Yes" if experiment.archived else "No")

    console.print(table)


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

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


@experiment_app.command("list-resources", rich_help_panel="Commands")
def list_experiment_resources(
    ctx: Context,
    name: Annotated[str, typer.Argument(..., help="Name or ID of experiment to show.", envvar="SLICES_EXPERIMENT")],
    include_expired: Annotated[
        bool, typer.Option("--show-expired/--hide-expired", "-e", help="List resources that have expired.")
    ] = False,
    include_terminated: Annotated[
        bool, typer.Option("--show-deleted/--hide-deleted", "-D", help="List resources that were deleted.")
    ] = False,
    show_ids: Annotated[bool, typer.Option("--show-ids", "-I", help="Show internal IDs.")] = False,
    format: Annotated[ListFormat, typer.Option("--format", "-f")] = "text",  # type: ignore
    sort: Annotated[ListExperimentResourcesSort, typer.Option("--sort", "-s", help="Field to sort by.")] = "created",  # type: ignore
):
    """Show experiment resources."""

    experiment = _get_experiment(ctx, name)

    now = datetime.now(UTC)
    if format == ListFormat.text and experiment.expires_at < now:
        humanized_delta = humanize.precisedelta(
            experiment.expires_at - now,
            minimum_unit="minutes",
            format="%0.0f",
        )
        console.print(
            f"[red]:clock10: Experiment {experiment.id if show_ids else experiment.friendly_name} "
            f"has expired on {friendly_datetime(experiment.expires_at)} (= {humanized_delta} ago)."
        )
        console.print(
            "To reuse it, you can add resources to it, "
            "renew the experiment container or "
            "create a new experiment with the same name."
        )
        return

    all_resources = ctx.obj.api.experiment.list_resources(
        experiment_id=experiment.id,
        include_expired=include_expired,
        include_terminated=include_terminated,
    )

    if sort == ListExperimentResourcesSort.name:
        all_resources = sorted(all_resources, key=lambda r: (r.infra_id, r.friendly_name))
    elif sort == ListExperimentResourcesSort.created:
        all_resources = sorted(all_resources, key=lambda r: (r.infra_id, r.created_at, r.friendly_name))
    elif sort == ListExperimentResourcesSort.status:
        all_resources = sorted(all_resources, key=lambda r: (r.infra_id, str(r.status), r.friendly_name))
    elif sort == ListExperimentResourcesSort.expires:
        all_resources = sorted(all_resources, key=lambda r: (r.infra_id, r.expires_at, r.friendly_name))

    if format == ListFormat.json:
        console.print_json(json_encode(list(all_resources)).decode())
        return
    if format == ListFormat.yaml:
        console.print(yaml_encode(list(all_resources)).decode())
        return

    cols = [
        "Friendly Name",
        "Infra",
        "Type",
        "Status",
        "Description",
        "Created At",
        "Expires At",
        "Terminated At",
    ]
    if show_ids:
        cols.insert(0, "ID")

    table = Table(
        *cols,
        title=f"Resources of experiment {experiment.friendly_name}",
    )
    for resource in all_resources:
        row = [
            resource.friendly_name,
            resource.infra_id,
            resource.type,
            resource.status,
            resource.description,
            friendly_datetime(resource.created_at),
            friendly_datetime(resource.expires_at) if resource.expires_at else "",
            friendly_datetime(resource.terminated_at) if resource.terminated_at else "",
        ]
        if show_ids:
            row.insert(0, resource.id)

        table.add_row(*row)

    console.print(table)


# def _extend_experiment_resources(ctx: Context, experiment: Experiment):
#     resources_page = ctx.obj.api.experiments_api.get_experiment_resources(
#         experiment_id=experiment.id,
#     )

#     # Get all BI infras in which this experiment has resources.
#     # Other types of resources can also be parsed here.
#     bi_infras_with_experiment_resources = set()
#     while resources_page:
#         bi_infras_with_experiment_resources.update(
#             {
#                 resource.infra_id
#                 for resource in resources_page.experiment_resources
#                 if resource.service_id == "bi" and resource.infra_id
#             }
#         )
#         marker = get_next_page_marker(resources_page.experiment_resource_links)
#         resources_page = (
#             ctx.obj.api.experiments_api.get_experiment_resources(
#                 experiment_id=experiment.id,
#                 marker=marker,
#             )
#             if marker
#             else None
#         )

#     experiment_token = ctx.obj.api.get_experiment_jwt(experiment.id)

#     if not bi_infras_with_experiment_resources:
#         console.print("[yellow]:information: No BI resources to extend.")

#     extend_errors = []
#     for infra_id in bi_infras_with_experiment_resources:
#         try:
#             ctx.obj.api.get_bi_resources_api(infra_id, experiment_token).update_resources(
#                 experiment.project_id,
#                 experiment.id,
#                 UpdateResourcesRequest(expires_at=experiment.expires_at),
#             )

#             console.print(f":recycle: Extended resources on infra {infra_id}")
#         except ApiException as err:  # noqa: PERF203
#             err_console.print(f"[bold red]Could not extend resources on infra {infra_id} :x:")
#             err_console.print(f"[red]{get_error_detail(err.body)}")
#             extend_errors.append(infra_id)

#     if extend_errors:
#         raise typer.Exit(1)


@experiment_app.command("extend-only-experiment-container", rich_help_panel="Advanced")
def extend_experiment_only(
    ctx: Context,
    name: Annotated[
        str, typer.Argument(help="Name or ID of experiment that must be extended.", envvar="SLICES_EXPERIMENT")
    ],
    expires_at: Annotated[
        datetime,
        typer.Option(
            "--duration",
            "-d",
            help="New expiration date or duration.",
            parser=duration_or_datetime,
        ),
    ],
    confirm_action: Annotated[
        bool,
        typer.Option(
            "--do-not-extend-any-resources", help="Confirm that you know this will not extend any resource at all."
        ),
    ] = False,
):
    """Extend the duration of an experiment "container".

    This does NOT extend any of the resources in that container. Use "slices bi extend" for that.

    This is an advanced command, that almost nobody ever never needs.
    If you want to extend resources, you don't need this.
    If you doubt you need this, you don't need this.
    """
    if not confirm_action:
        err_console.print(
            "[red][bold]Not doing anything. [/bold]You need to confirm you want to extend [bold]ONLY[/bold] "
            "the experiment container, and [bold]NOT ANY RESOURCES[/bold]. :x:"
        )
        err_console.print(
            "[red]Almost nobody ever needs to do this! "
            "[/red]If you really need to do this (but you don't), read --help carefully."
        )
        err_console.print("")
        err_console.print("[green]The command you want to use instead is: [green bold]slices bi extend")
        err_console.print("")
        raise typer.Exit(1)
    experiment = _get_experiment(ctx, name)

    try:
        extended_experiment = ctx.obj.api.experiment.update(
            experiment.id,
            experiment.project_id,
            expires_at=expires_at,
        )
    except ApiException as err:
        err_console.print("[bold red]Could not extend experiment :x:")
        err_console.print(f"[red]{get_api_error_detail(err)}")
        raise typer.Exit(1) from err

    console.print(
        f":recycle: Extended experiment '{extended_experiment.friendly_name}' "
        f"with ID '{extended_experiment.id}' until {friendly_datetime(extended_experiment.expires_at)}"
    )

    console.print("[red]:warning: Resources inside this experiment were not extended. :warning:")


# @experiment_app.command("extend", rich_help_panel="Commands")
# def extend_experiment(
#     ctx: Context,
#     name: Annotated[str, typer.Argument(help="Name or ID of experiment that must be extended.")],
#     expiration: Annotated[
#         datetime,
#         typer.Argument(help="New expiration date or duration.", parser=duration_or_datetime),
#     ],
#     all_resources: Annotated[
#         bool,
#         typer.Option(
#             "--all-resources/--skip-resources",
#             "-A/-S",
#             help="Extend all resources in the experiment to the same date.",
#             is_flag=True,
#         ),
#     ] = True,
# ):
#     """Extend the duration of an experiment."""

#     if is_experiment_id(name):
#         experiment_id = name
#     else:
#         experiments = ctx.obj.api.experiments_api.get_experiments(
#             project_id=ctx.obj.require_project_id(),
#             friendly_name=name,
#         )

#         if not len(experiments.experiments) > 0:
#             err_console.print(f"[red]Could not find experiment with name {name} :magnifying_glass_tilted_left:")
#             raise typer.Exit(1)

#         experiment_id = experiments.experiments[0].id

#     try:
#         extended_experiment = ctx.obj.api.experiments_api.update_experiment(
#             experiment_id, ExperimentUpdate(expires_at=datetime_with_local_timezone(expiration))
#         )
#     except ApiException as err:
#         err_console.print("[bold red]Could not extend experiment :x:")
#         err_console.print(f"[red]{get_error_detail(err.body)}")
#         raise typer.Exit(1) from err

#     console.print(
#         f":recycle: Extended experiment '{extended_experiment.friendly_name}' "
#         f"with ID '{extended_experiment.id}' until {friendly_datetime(extended_experiment.expires_at)}"
#     )

#     if not all_resources:
#         console.print("[yellow]:warning: Resources were not extended. :warning:")
#     else:
#         _extend_experiment_resources(ctx, extended_experiment)


@experiment_app.command("delete", rich_help_panel="Commands")
def delete_experiment(
    ctx: Context,
    name: Annotated[
        str, typer.Argument(..., help="Name or ID of experiment that must be deleted.", envvar="SLICES_EXPERIMENT")
    ],
    force: Annotated[bool, typer.Option("--force", "-f", help="Do not ask for confirmation", is_flag=True)] = False,
):
    """Delete an experiment by name or ID."""
    if is_experiment_id(name):
        experiment_id = name
    else:
        experiments = ctx.obj.api.experiment.list(
            project_id=ctx.obj.require_project_id(),
            friendly_name=name,
        )

        if not len(experiments):
            err_console.print(f"[red]Could not find experiment with name {name} :magnifying_glass_tilted_left:")
            raise typer.Exit(1)

        experiment_id = next(iter(experiments)).id

        if not force:
            typer.confirm(f"Are you sure you want to delete experiment {name} with ID '{experiment_id}'?", abort=True)

    try:
        deleted_experiment = ctx.obj.api.experiment.delete(experiment_id)
    except ApiException as err:
        err_console.print("[bold red]Could not delete experiment :x:")
        err_console.print(f"[red]{get_api_error_detail(err)}")
        raise typer.Exit(1) from err

    console.print(
        f":wastebasket: Deleted experiment '{deleted_experiment.friendly_name}' with ID '{deleted_experiment.id}'"
    )


@experiment_app.command(
    "jwt",
    help="Get credentials for a specific experiment.",
    # hidden=True,
    rich_help_panel="Advanced",
)
def retrieve_jwt(
    ctx: Context,
    name: Annotated[str, typer.Argument(..., help="Name or ID of experiment.", envvar="SLICES_EXPERIMENT")],
    decode: Annotated[bool, typer.Option("--decode", "-d", help="Decode the token.")] = False,
):
    """Print experiment credentials."""
    experiment = _get_experiment(ctx, name)

    try:
        token = ctx.obj.api.get_experiment_jwt(experiment.id)

        if decode:
            decoded_token = jwt.decode(token, options={"verify_signature": False})
            console.print_json(json.dumps(decoded_token))
        else:
            print(token)
    except HTTPError as e:
        if e.response:
            try:
                error_response = e.response.json()
                err_console.print(
                    f"[bold red]Error: Could not get token. :x:[/]\n[red]{error_response['error_description']}"
                )
            except:  # noqa: E722
                err_console.print("[bold red]Error: Could not get token. :x:")
                err_console.print(f"[red]{str(e.response.text)}")
        else:
            err_console.print("[bold red]Error: Could not get token. :x:")
        raise typer.Exit(1) from None


class WaitResourcesOutputFormat(Enum):
    """Output format of the --wait command."""

    console = "console"  # text output that overrides the previous line for each new status check
    text = "text"  # text output with a new line for each new status check
    json = "json"  # one json array per status check
    yaml = "yaml"  # one yaml fragment (multi-line) per status check


SIMPLE_PROGRESS_CHARS = ["/", "-", "\\", "|"]


@experiment_app.command("wait", rich_help_panel="Commands")
def wait_for_resource_status(  # noqa: C901, PLR0912, PLR0915 TODO somehow simplify or split this function
    ctx: Context,
    experiment_name: Annotated[
        str, typer.Argument(help="Name or ID of experiment to show.", envvar="SLICES_EXPERIMENT")
    ],
    resource_names: Annotated[
        list[str] | None,
        typer.Argument(
            help="Name or ID of the resources to wait for. May be empty to wait for all resources.",
        ),
    ] = None,
    status: Annotated[
        ResourceState,
        typer.Option(case_sensitive=False, callback=lambda v: v.lower(), help="The status for which to wait."),
    ] = ResourceState.UP,
    timeout: Annotated[
        str,
        typer.Option(
            callback=validate_timedelta_str,
            help=f"How long needs to be waited for the resources to reach the requested status? "
            f"Format: {STR_TO_TIMEDELTA_HELP}",
        ),
    ] = "10 minutes",
    interval: Annotated[
        str,
        typer.Option(
            callback=validate_timedelta_str,
            help=f"Time between status checks. Format: {STR_TO_TIMEDELTA_HELP}",
        ),
    ] = "1s",
    format: Annotated[
        WaitResourcesOutputFormat,
        typer.Option(
            case_sensitive=False,
            help='The output format. "console" will print a table.'
            "If in a terminal, this table will be updated instead of printing a new table.",
        ),
    ] = "console",  # pyright: ignore [reportArgumentType]  weird but this works
    include_expired: Annotated[bool, typer.Option(help="Do not ignore EXPIRED Resources.")] = False,
    include_terminated: Annotated[bool, typer.Option(help="Do not ignore DELETED Resources.")] = False,
    ignore_missing: Annotated[
        bool,
        typer.Option(
            help="Do not fail if one of the specified resources does not exist."
            "This is useful if the resource will be created after starting the wait."
        ),
    ] = False,
):
    """Wait for the specified resources in the experiment to reach a requested status (default UP).

    If no resources are specified, wait for all resources in the experiment.
    Exit with exit status 1 if the resources can never reach the target state (= they become FAILED or DELETED).
    Exit with exit status 2 if the resources did not reach the target state before the timeout.
    """

    start = datetime.now(UTC)
    deadline = start + str_to_timedelta(timeout)
    interval_s = str_to_timedelta(interval).total_seconds()

    all_resources = resource_names is None

    experiment = _get_experiment(ctx, experiment_name)

    progress = None
    progress_resources: dict[str, TaskID] = {}
    progress_last_status: dict[str, str] = {}

    simple_progress = False
    simple_progress_index = 0

    if format == WaitResourcesOutputFormat.console and console.is_terminal and not console.is_dumb_terminal:
        progress = Progress(SpinnerColumn(), *Progress.get_default_columns(), console=console, transient=True)
        progress.start()

    if format == WaitResourcesOutputFormat.console and console.is_terminal and console.is_dumb_terminal:
        simple_progress = True

    ready = False
    first = True

    try:
        if format == WaitResourcesOutputFormat.text:
            console.print(f"Waiting for {status.name!r}...")
        if simple_progress:
            console.print(f"Waiting for {status.name!r}...", end="\r")

        while not ready:
            if not first:
                if simple_progress:
                    console.print(
                        f"Waiting for {status.name!r}... "
                        f"{SIMPLE_PROGRESS_CHARS[simple_progress_index % len(SIMPLE_PROGRESS_CHARS)]}",
                        end="\r",
                    )
                    simple_progress_index += 1

                if datetime.now(UTC) > deadline:
                    if progress:
                        progress.stop()
                    if simple_progress:
                        console.print("                                           ", end="\r")
                    if all_resources:
                        rname = "All resources"
                    elif len(resource_names) == 1:
                        rname = f"Resource {resource_names[0]!r}"
                    else:
                        rname = "Resources"
                    err_console.print(
                        f"[red]{rname} did not become {status.name} before the requested timeout ({timeout}):x:"
                    )
                    raise typer.Exit(2)

                time.sleep(interval_s)

            experiment_resources = ctx.obj.api.experiment.list_resources(
                experiment_id=experiment.id,
                include_expired=include_expired,
                include_terminated=include_terminated,
            )

            new_ready = True
            res = []

            def _resource_to_dict(resource: ExperimentResource) -> dict:
                return {
                    "id": resource.id,
                    "friendly_name": resource.friendly_name,
                    "status": resource.status.name,
                }

            for resource in experiment_resources:
                if all_resources or resource.id in resource_names or resource.friendly_name in resource_names:
                    res.append(_resource_to_dict(resource))  # noqa: PERF401
                    new_ready = new_ready and resource.status == status
                    if (
                        status == ResourceState.FAILED
                        and status
                        not in (
                            ResourceState.FAILED,
                            ResourceState.DELETED,
                        )
                    ) or (status == ResourceState.DELETED and status != ResourceState.DELETED):
                        if progress:
                            progress.stop()
                        if simple_progress:
                            console.print("                                           ", end="\r")
                        err_console.print(
                            f"[red]Resource {resource.friendly_name} has status {resource.status.name}: "
                            f"it can never become {status.name}. :x:"
                        )
                        raise typer.Exit(1)

            if all_resources and len(res) == 0 and not ignore_missing:
                err_console.print("[red]No resources found in the experiment. :x:")
                raise typer.Exit(1)

            saw_all = all_resources or len(resource_names) == len(res)
            if not saw_all and not ignore_missing:
                if progress:
                    progress.stop()
                if simple_progress:
                    console.print("                                           ", end="\r")
                seen_names = [r["friendly_name"] for r in res]
                seen_ids = [r["id"] for r in res]
                assert resource_names
                missing_resources = [
                    rname for rname in resource_names if rname not in seen_names and rname not in seen_ids
                ]
                err_console.print(f"[red]Not all requested resources seen, missing: {', '.join(missing_resources)} :x:")
                raise typer.Exit(1)

            now = datetime.now(UTC).astimezone()
            if format == WaitResourcesOutputFormat.console and simple_progress:
                pass
            elif format == WaitResourcesOutputFormat.console and progress:
                assert progress
                assert progress_resources is not None

                for r in res:
                    if r["id"] not in progress_resources:
                        progress_resources[r["id"]] = progress.add_task(r["friendly_name"], total=None)
                    ready = r["status"] == status.name

                    progress.update(
                        progress_resources[r["id"]],
                        description=f"{r['friendly_name']} is currently {r['status']}",
                        completed=1 if ready else 0,
                        total=1 if ready else None,
                    )

                    if r["id"] in progress_last_status and progress_last_status[r["id"]] != r["status"]:
                        progress.log(
                            f"Resource {r['friendly_name']!r} changed status "
                            f"from {progress_last_status[r['id']]!r} "
                            f"to {r['status']!r}"
                        )
                    progress_last_status[r["id"]] = r["status"]

            elif format == WaitResourcesOutputFormat.text:
                for r in res:
                    console.print(
                        f"{friendly_datetime(now, include_seconds=True)} "
                        f"- Resource {r['friendly_name']!r} - Status {r['status']!r}"
                    )
            elif format == WaitResourcesOutputFormat.json:
                if console.is_terminal:
                    console.print_json(json.dumps(res))
                else:
                    console.print(json.dumps(res))
            elif format == WaitResourcesOutputFormat.yaml:
                console.print("---")
                console.print(yaml.safe_dump(res))
            else:
                raise ValueError(f"Unimplemented format requested: {format}")

            ready = new_ready
            first = False
    finally:
        if progress:
            progress.stop()
        if simple_progress:
            console.print("                                           ", end="\r")

    if format in (WaitResourcesOutputFormat.console, WaitResourcesOutputFormat.text):
        console.print(f"Status {status.name!r} reached.")
