"""Experiment CLI."""

import sys
from enum import Enum
from subprocess import PIPE, Popen, TimeoutExpired
from typing import Annotated, Any

import typer
from msgspec.json import encode as json_encode
from msgspec.yaml import encode as yaml_encode
from rich.table import Table
from slices_clientlib_core.base.exceptions import ApiException, ConflictException
from slices_clientlib_core.experiment import ExperimentMemberRole, ExperimentResource
from slices_clientlib_core.project import ProjectMember

from slices_cli_core import console, err_console
from slices_cli_core.core.context import Context
from slices_cli_core.core.users_info import USER_ID_HELP
from slices_cli_core.util.errors import get_api_error_detail
from slices_cli_core.util.shell import shell_args_to_line
from slices_cli_core.util.type_id import is_experiment_id

experiment_member_app = typer.Typer(
    help="""Commands to list/add/remove experiment members.

For some commands, you need the ID of other users.
You can find these with the command: slices project show""",
    short_help="Commands to list/add/remove experiment members",
    no_args_is_help=True,
)


def validate_user_id(ctx: typer.Context, value: str):
    """Validate user ID, resolving it if it is not a TypeID. Always return TypeID as str."""
    return ctx.obj.other_users_info.resolve_project_member(ctx.obj.require_project_id(), value)


def validate_user_ids(ctx: typer.Context, value: list[str] | None):
    """Validate multiple user IDs."""
    if not value:
        return []
    return [validate_user_id(ctx, u) for u in value]


def _get_experiment_id(ctx: Context, name: str) -> str:
    if is_experiment_id(name):
        return name
    experiments = ctx.obj.api.experiment.list(
        project_id=ctx.obj.require_project_id(),
        friendly_name=name,
    )
    try:
        experiment = next(iter(experiments))
    except StopIteration:
        err_console.print(f"[red]Could not find experiment with name {name} :magnifying_glass_tilted_left:")
        raise typer.Exit(1) from None
    else:
        return experiment.id


ALL_ROLES = [k.lower() for k in ExperimentMemberRole.__members__]
ALL_ALLOWED_ROLES = [k for k in ALL_ROLES if k != "lead"]
ALL_ALLOWED_ROLES_STR = "/".join(ALL_ALLOWED_ROLES)


def validate_role(value: str | None) -> str | None:
    """Validate the experiment member role."""

    if value is None:
        return None

    value = value.lower()
    if value not in ALL_ROLES:
        raise typer.BadParameter(f"Invalid role {value!r}. Allowed roles: {'/'.join(ALL_ALLOWED_ROLES)}")
    if value not in ALL_ALLOWED_ROLES:
        raise typer.BadParameter(
            f"May not change role of user in experiment to {value!r}. Allowed roles: {'/'.join(ALL_ALLOWED_ROLES)}"
        )
    return value


def _map_project_role_to_experiment_role(project_role: str) -> ExperimentMemberRole:
    """Explicit mapping of project roles to experiment roles."""

    # project has member roles: 'lead', 'admin', 'member', 'viewer'
    if project_role.lower() == "lead":
        return ExperimentMemberRole.ADMIN  # type: ignore[reportReturnType]
    if project_role.lower() == "admin":
        return ExperimentMemberRole.ADMIN  # type: ignore[reportReturnType]
    if project_role.lower() == "member":
        return ExperimentMemberRole.MEMBER  # type: ignore[reportReturnType]
    if project_role.lower() == "viewer":
        return ExperimentMemberRole.VIEWER  # type: ignore[reportReturnType]
    raise ValueError(f"Unsupported project role {project_role!r}")


def _is_bi_resource(resource: ExperimentResource) -> bool:
    """Determine whether a resource is a BI resource."""
    parts = resource.infra_id.split("-")
    return len(parts) >= 4 and parts[2] == "bi"  # noqa: PLR2004


def do_add_missing_pubkeys(ctx: Context, _experiment_name: str, experiment_id: str, *, member_id: str):
    """Add missing public SSH keys to ~/.ssh/authorized_hosts."""
    member_name = ctx.obj.other_users_info.try_get_user_full_name(member_id)

    # Find member pubkeys
    pubkeys = ctx.obj.api.public_key.list_for_user(member_id)
    if not pubkeys:
        # nothing to do
        console.print(
            f"  [dark_goldenrod]:warning: [bold]{member_name}[/bold] has not registered public SSH keys.",
        )
        console.print(
            "  Tip: Advise this user to add a public SSH key using: [blue]slices pubkey register <filename>",
        )
        return

    # Find resources
    all_resources = ctx.obj.api.experiment.list_resources(
        experiment_id=experiment_id,
        include_expired=False,
        include_terminated=False,
    )

    if not all_resources:
        console.print(
            "  [bright_black]There are no active resources on which to add public SSH keys.",
        )

    for resource in all_resources:
        if not _is_bi_resource(resource):
            continue

        full_command = [
            "slices",
            "bi",
            "--infra",
            resource.infra_id,
            "add_authorized_keys",
            "--experiment",
            experiment_id,
            "--member-name",
            member_name,
            resource.id,
            *[p.public_key for p in pubkeys],
        ]
        printable_command = shell_args_to_line(full_command)
        console.print("Calling: " + printable_command)
        try:
            with Popen(  # noqa: S603
                ["/usr/bin/sh", "-s"],
                stdout=sys.stdout,
                stderr=sys.stderr,
                stdin=PIPE,
                universal_newlines=True,
            ) as proc:
                try:
                    proc.communicate(input=printable_command, timeout=60)
                except TimeoutExpired:
                    console.print("[bold red]Timeout trying to modify authorized_keys")
                    proc.kill()
                    proc.communicate()
                else:
                    res_code = proc.wait()
                    if res_code != 0:
                        console.print("[bold red]Failed to modify authorized_keys")
        except:  # noqa: E722
            console.print("[bold red]Failed to execute command")


def _resolve_target_role(role: str | None, user_id: str, project_members: list[ProjectMember]) -> str:
    if role is not None:
        return str(role).lower()

    for m in project_members:
        if m.user_id == user_id:
            return m.role

    err_console.print("[red]User is not a member of the current project. :x:")
    err_console.print("[bright_black]If the user is not in the project, you need to specify their role explicitly.")
    raise typer.Exit(1)


def _get_target_users(
    ctx: Context,
    user_ids: list[str] | None,
    role: str | None,
    all_project_members: bool,
) -> list[tuple[str, str]]:
    """Resolve target users and their requested role.

    :returns: A list of (user ID, role) pairs.
    """
    # print(f"_get_target_users({user_ids!r}, {role!r}, {all_project_members!r})")
    project_members = []
    if all_project_members or role is None:
        current_project_id = ctx.obj.get_current_project_id()

        if not current_project_id:
            err_console.print("[red]No project set. :x:")
            raise typer.Exit

        project_members = ctx.obj.api.project.list_members(current_project_id)

    if all_project_members:
        if user_ids:
            err_console.print(
                "[red]You may not specify user IDs AND the --add-project-members option at the same time. :x:"
            )
            raise typer.Exit(1)
        user_ids = [m.user_id for m in project_members]
    elif not user_ids:
        err_console.print("[red]You must specify at least one user (unless --add-project-members is used) :x:")
        raise typer.Exit(1)

    if role is None:
        return [(u, _resolve_target_role(role, u, project_members)) for u in user_ids]
    return [(u, str(role).lower()) for u in user_ids]


def _exp_role_to_str(role: Any) -> str:
    """Convert a role to a string."""

    if isinstance(role, str):
        return role.upper()
    if isinstance(role, Enum):
        return role.name.upper()
    return str(role).upper()


@experiment_member_app.command(name="add", rich_help_panel="Commands")
def add_experiment_member(
    ctx: Context,
    experiment_name: Annotated[
        str,
        typer.Argument(
            help="Name or ID of the experiment.",
            show_default=False,
        ),
    ],
    user_id: Annotated[
        list[str] | None,
        typer.Argument(
            help=f"ID(s) of the user(s). {USER_ID_HELP}. "
            f"Instead of specifying users here, you can also use --all-project-members. ",
            callback=validate_user_ids,
            show_default=False,
        ),
    ] = None,
    role: Annotated[
        str | None,
        typer.Option(
            help=f"Role of the user ({ALL_ALLOWED_ROLES_STR}). "
            f"If not specified, the role that the user has in the project is used.",
            callback=validate_role,
        ),
    ] = None,
    add_ssh_keys: Annotated[
        bool,
        typer.Option(help="Add public SSH keys of the user to ~/.ssh/authorized_keys on all active BI resources"),
    ] = True,
    all_project_members: Annotated[
        bool,
        typer.Option(
            "--all-project-members",
            help="Instead of specifying users, use all members of the current project",
            show_default=False,
        ),
    ] = False,
):
    """Add or update experiment member.

    If the user is not yet a member of the experiment, it will be added.
    If the user was already a member, its role will be updated to the requested role.
    """
    #     You can find the user ID of other users with the command: slices project show

    experiment_id = _get_experiment_id(ctx, experiment_name)

    user_role_pairs = _get_target_users(ctx, user_id, role, all_project_members)

    experiment_members = list(ctx.obj.api.experiment.member(experiment_id).list())
    experiment_member_ids = {m.user_id for m in experiment_members}

    has_err = False
    for resolved_user_id, target_role_str in user_role_pairs:
        is_exp_member = resolved_user_id in experiment_member_ids

        if ctx.obj.user_info and resolved_user_id == ctx.obj.user_info["sub"]:
            # Skip the user itself
            continue

        exp_role = _map_project_role_to_experiment_role(target_role_str)
        if not is_exp_member:
            try:
                ctx.obj.api.experiment.member(experiment_id).create(
                    role=exp_role,
                    user_id=resolved_user_id,
                )
                console.print(
                    f"[green]:heavy_check_mark: "
                    f"[bold]{ctx.obj.other_users_info.try_get_user_full_name(resolved_user_id)}[/bold] added "
                    f"with role [italic]{_exp_role_to_str(exp_role)}[/italic]",
                )
            except ConflictException:
                err_console.print(
                    f"[bold red]CONFLICT: User "
                    f"[bold]{ctx.obj.other_users_info.try_get_user_full_name(resolved_user_id)}[/bold] "
                    f"is already a member of the experiment,"
                    f" yet this user is not in the list of experiment members. :x:"
                )
                has_err = True
            except ApiException as err:
                err_console.print(f"[bold red]Could not add experiment member {resolved_user_id} :x:")
                err_console.print(f"[red]{get_api_error_detail(err)}")
                has_err = True
        else:
            experiment_member = [m for m in experiment_members if m.user_id == resolved_user_id][0]
            target_role = exp_role
            if _exp_role_to_str(target_role) == _exp_role_to_str(experiment_member.role):
                console.print(
                    f":heavy_check_mark: "
                    f"[bold]{ctx.obj.other_users_info.try_get_user_full_name(resolved_user_id)}[/bold] "
                    f"was already a member with role [italic]{_exp_role_to_str(target_role)}[/italic]",
                )
            else:
                try:
                    updated_experiment_member = ctx.obj.api.experiment.member(experiment_id).update(
                        resolved_user_id,
                        target_role,
                    )
                    console.print(
                        f"[green]:heavy_check_mark: "
                        f"[bold]{ctx.obj.other_users_info.try_get_user_full_name(resolved_user_id)}[/bold] "
                        f"with role [italic]{_exp_role_to_str(experiment_member.role)}[/italic] was updated to "
                        f"role [italic]{_exp_role_to_str(updated_experiment_member.role)}[/italic]",
                    )
                except ApiException as err:
                    err_console.print(f"[bold red]Could not update experiment member {resolved_user_id} :x:")
                    err_console.print(f"[red]{get_api_error_detail(err)}")
                    has_err = True

        if add_ssh_keys:  # and IS_SLICES_BI_INSTALLED:
            try:
                do_add_missing_pubkeys(ctx, experiment_name, experiment_id, member_id=resolved_user_id)
            except ApiException as err:
                err_console.print(f"[bold red]Could not update SSH keys for experiment member {resolved_user_id} :x:")
                err_console.print(f"[red]{get_api_error_detail(err)}")
                has_err = True

    if has_err:
        raise typer.Exit(1)


@experiment_member_app.command(name="transfer-lead", rich_help_panel="Advanced")
def transfer_experiment_lead(
    ctx: Context,
    experiment_name: Annotated[str, typer.Argument(help="Name or ID of the experiment.")],
    user_id: Annotated[
        str,
        typer.Argument(
            help=f"ID of the new LEAD user. {USER_ID_HELP}",
            callback=validate_user_id,
        ),
    ],
):
    """Transfer the LEAD of the experiment from you to another member of the experiment.

    You can find the user IDs with the command: slices experiment member list
    """

    experiment_id = _get_experiment_id(ctx, experiment_name)

    try:
        _experiment = ctx.obj.api.experiment.member(experiment_id).update(
            user_id,
            ExperimentMemberRole.LEAD,  # type: ignore[reportReturnType]
        )
        console.print(
            f"[green]:heavy_check_mark: Transferred experiment {experiment_name!r} LEAD to [bold]{user_id}[/bold] "
        )
    except ApiException as err:
        err_console.print("[bold red]Could not transfer experiment LEAD (are you the LEAD?) :x:")
        err_console.print(f"[red]{get_api_error_detail(err)}")
        raise typer.Exit(1) from err


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

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


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

    first_name = "first_name"
    last_name = "last_name"
    role = "role"
    email = "email"
    username = "username"
    # organisation = "organisation"


@experiment_member_app.command(name="list", rich_help_panel="Commands")
def list_experiment_members(
    ctx: Context,
    experiment_name: Annotated[str, typer.Argument(help="Name or ID of the experiment.", envvar="SLICES_EXPERIMENT")],
    show_details: Annotated[
        bool,
        typer.Option(
            "--show-details/--no-show-details",
            "-I",
            help="Show internal user IDs, email, and usernames",
        ),
    ] = True,
    format: Annotated[ListFormat, typer.Option("--format", "-f")] = "text",  # type: ignore
    sort: Annotated[ListMemberSort, typer.Option("--sort", "-s", help="Field to sort by.")] = "last_name",  # type: ignore
):
    """List experiment members."""

    experiment_id = _get_experiment_id(ctx, experiment_name)

    all_experiment_members_extra = ctx.obj.other_users_info.get_experiment_members(experiment_id)

    if sort == ListMemberSort.username:
        all_experiment_members_extra.sort(key=lambda m: (m.preferred_username, m.first_name, m.last_name, m.role))
    elif sort == ListMemberSort.last_name:
        all_experiment_members_extra.sort(key=lambda m: (m.last_name, m.first_name, m.role))
    elif sort == ListMemberSort.first_name:
        all_experiment_members_extra.sort(key=lambda m: (m.first_name, m.last_name, m.role))
    elif sort == ListMemberSort.role:
        all_experiment_members_extra.sort(key=lambda m: (m.role, m.last_name, m.first_name))
    # elif sort == ListMemberSort.organisation:
    #     all_experiment_members_extra.sort(key=lambda m: (m.organisation, m.last_name, m.first_name))
    elif sort == ListMemberSort.email:
        all_experiment_members_extra.sort(key=lambda m: (m.email, m.last_name, m.first_name, m.role))

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

    if show_details:
        table = Table("User ID", "Name", "Username", "Email", "Role", title=f"Members of experiment {experiment_name}")
        for member in all_experiment_members_extra:
            table.add_row(
                member.user_id, member.full_name, member.preferred_username, member.email, _exp_role_to_str(member.role)
            )
    else:
        table = Table("User", "Role", title=f"Members of experiment {experiment_name}")
        for member in all_experiment_members_extra:
            table.add_row(member.full_name, _exp_role_to_str(member.role))

    console.print(table)


@experiment_member_app.command("remove", rich_help_panel="Commands")
def remove_experiment_member(
    ctx: Context,
    experiment_name: Annotated[str, typer.Argument(..., help="Name or ID of experiment.")],
    user_id: Annotated[str, typer.Argument(..., help="User ID to remove from experiment.", callback=validate_user_id)],
):
    """Remove an experiment member.

    This does NOT remove SSH access to active resources.
    """

    experiment_id = _get_experiment_id(ctx, experiment_name)

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

    console.print(
        f":wastebasket: Removed experiment member "
        f"'{ctx.obj.other_users_info.try_get_user_full_name(deleted_experiment_member.user_id)}' "
        f"from experiment {experiment_name}."
    )
