#  -----------------------------------------------------------------------------------------
#  (C) Copyright IBM Corp. 2025.
#  https://opensource.org/licenses/BSD-3-Clause
#  -----------------------------------------------------------------------------------------

import json
import os
import subprocess
import urllib.parse
from pathlib import Path
from typing import Any, Literal

import tomli
import typer
from ibm_watsonx_ai import APIClient, Credentials  # type: ignore[import-untyped]

from ibm_watsonx_ai_cli.utils.config import (
    get_deployment_id,
    get_deployment_space_id,
    get_deployment_url,
    get_payload_path,
    load_config,
)

REPO_ZIP_URL = (
    "https://github.com/IBM/watsonx-developer-hub/archive/refs/heads/main.zip"
)
EXTRACTED_REPO_DIR = "watsonx-developer-hub-main"
AGENTS_SUBDIR = "agents"


def get_from_env(
    key: str, env_key: str, default: str | None = None, allow_empty: bool = False
) -> str | None:
    """
    Retrieve the value of an environment variable, ensuring it is non-empty.

    Parameters:
        key (str): A descriptive name for the variable (used in error messages).
        env_key (str): The name of the environment variable to retrieve.
        default (str | None): The default value to return if the environment variable
            is not set or is empty. Defaults to None.
        allow_empty (bool): Skip raise error, Default to False.

    Returns:
        str: The non-empty value of the environment variable, or the default value if provided.

    Raises:
        ValueError: If the environment variable is not set or is empty and no default is provided.
    """
    value = os.environ.get(env_key, "").strip()
    if value:
        return value
    elif default is not None:
        return default
    else:
        if not allow_empty:
            raise ValueError(
                f"Did not find {key}. Please set environment variable `{env_key}` with a valid value."
            )
        return None


def get_project_or_space_from_env() -> dict:
    """
    Get project_id or space_id from environment variable.

    Raises:
        ValueError: If not exactly one of `WATSONX_SPACE_ID` or `WATSONX_PROJECT_ID` is set.
    """
    space_id = os.environ.get("WATSONX_SPACE_ID", "").strip() or None
    project_id = os.environ.get("WATSONX_PROJECT_ID", "").strip() or None

    if (bool(space_id) and bool(project_id)) or (not space_id and not project_id):
        raise ValueError(
            "Please ensure that only one of the environment variables, `WATSONX_SPACE_ID` or `WATSONX_PROJECT_ID`, is set. Do not set both simultaneously."
        )

    return {"space_id": space_id, "project_id": project_id}


def prepare_client() -> APIClient:
    """
    Prepares and returns an initialized IBM watsonx.ai APIClient.

    Returns:
        APIClient: An initialized API client created using the retrieved configuration.
    """
    try:
        config = load_config()
        dep_config = config["deployment"]

        url = dep_config["watsonx_url"]
        api_key = dep_config.get("watsonx_apikey") or None
        token = dep_config.get("watsonx_token") or None
        password = dep_config.get("watsonx_password") or None
        username = dep_config.get("watsonx_username") or None
        instance_id = dep_config.get("watsonx_instance_id") or None

        project_space_dict = {
            "space_id": dep_config.get("space_id", None),
            "project_id": dep_config.get("project_id", None),
        }

    except FileNotFoundError:
        url = get_from_env("watsonx_url", "WATSONX_URL", allow_empty=False)

        api_key = get_from_env("watsonx_apikey", "WATSONX_APIKEY", allow_empty=True)
        token = get_from_env("watsonx_token", "WATSONX_TOKEN", allow_empty=True)
        password = get_from_env(
            "watsonx_password", "WATSONX_PASSWORD", allow_empty=True
        )
        username = get_from_env(
            "watsonx_username", "WATSONX_USERNAME", allow_empty=True
        )
        instance_id = get_from_env(
            "watsonx_instance_id", "WATSONX_INSTANCE_ID", allow_empty=True
        )
        if is_cloud_environment(url=url):
            if api_key is None and token is None:
                raise ValueError(
                    "Did not find `watsonx_apikey` or `watsonx_token`. Please set environment variable `WATSONX_APIKEY` or `WATSONX_TOKEN` with a valid value."
                )
        else:
            if api_key is None and token is None and password is None:
                raise ValueError(
                    "Did not find `watsonx_apikey`, `watsonx_token` or `watsonx_password`. Please set environment variable `WATSONX_APIKEY`, `WATSONX_TOKEN` or `WATSONX_PASSWORD` with a valid value."
                )
            if (api_key or password) is not None and username is None:
                raise ValueError(
                    "Did not find `watsonx_username`. Please set environment variable `WATSONX_USERNAME` with a valid value."
                )
            if instance_id is None:
                raise ValueError(
                    "Did not find `watsonx_instance_id`. Please set environment variable `WATSONX_INSTANCE_ID` with a valid value."
                )

        project_space_dict = get_project_or_space_from_env()

    return APIClient(
        credentials=Credentials(
            url=url,
            api_key=api_key,
            token=token,
            password=password,
            username=username,
            instance_id=instance_id,
        ),
        space_id=project_space_dict["space_id"],
        project_id=project_space_dict["project_id"],
    )


def prepare_resources_prompt(
    resources: list, resource_type: Literal["template", "app"], ask: bool = False
) -> str:
    """
    Construct a formatted prompt listing available resources.

    Args:
        resources (list): A list of available template names.
        ask (bool): If True, append a question prompting the user to choose a template. Defaults to False.
        resource_type: (Literal["template", "app"]): Type of resources to formatted into a prompt. Supported:
            - template (default)
            - app
    Returns:
        str: The formatted prompt string.
    """
    prompt = typer.style(
        f"\nList of available {resource_type}s:\n\n", fg="bright_blue", bold=True
    )
    for i, resource in enumerate(resources):
        prompt += typer.style(f"{i + 1}. {resource}\n")
    if ask:
        prompt += typer.style(
            f"\nWhich {resource_type} do you want to start with?",
            fg="bright_blue",
            bold=True,
        )

    return prompt


def get_package_root(
    agent_dir: Path | None = None, config_file: str = "pyproject.toml"
) -> Path:
    """
    Determine whether a given directory (or the current working directory) contains
    a specified configuration file and return that directory.

    Args:
        agent_dir (Path | None): Optional subdirectory under cwd to inspect.
            If None, uses cwd directly. Defaults to None.
        config_file (str): Name of the configuration file to look for in the target directory.
            Defaults to "pyproject.toml".

    Returns:
        Path: The directory in which `config_file` was found.

    Raises:
        FileNotFoundError: If `config_file` is not present in the target directory.
    """
    base_dir = Path.cwd()
    if agent_dir:
        base_dir = base_dir / agent_dir

    config_file_path = base_dir / config_file

    if config_file_path.is_file():
        return base_dir

    raise FileNotFoundError(f"No '{config_file}' found in directory {base_dir}.")


def is_cli_compatible(project_directory: Path) -> bool:
    """
    Check if the given project directory contains a CLI-compatible config file.

    Args:
        project_directory (Path): The path to the project directory.

    Returns:
        bool: True if a CLI-compatible config file is found, False otherwise.
    """
    config_files = ["config.toml", "config.toml.example"]

    for config_file in config_files:
        if (project_directory / config_file).is_file():
            return True

    return False


def ensure_template_is_cli_compatible(project_directory: Path, cli_method: str) -> None:
    """
    Validates whether the project template in the given directory is compatible with the CLI.

    Args:
        project_directory (Path): The root directory of the project to validate.
        cli_method (str): The CLI method being used, included in the error message if incompatible.

    Raises:
        typer.Exit: If the template is not compatible, exits the CLI with error code 1.
    """
    cli_compatible = is_cli_compatible(project_directory=project_directory)

    if not cli_compatible:
        error_msg = (
            f"The template located in the directory {project_directory} is "
            f"incompatible with the CLI `{cli_method}` method. "
        )

        readme_file = project_directory / "README.md"
        if readme_file.is_file():
            error_msg += f"Please refer to the {readme_file} file for further details."

        typer.echo(typer.style(error_msg, fg="bright_red", bold=True))
        raise typer.Exit(code=1)


def resource_exists(resource: str, available_resources: list) -> bool:
    """
    Check if a given resource exists within the available resources list.

    The function treats the input as a full resource path if it contains a "/".
    Otherwise, it checks if any available resource ends with "/{resource}".

    Args:
        resource (str): The resource name or full path to check.
        available_resources (list): List of available resource paths.

    Returns:
        bool: True if the resource exists, False otherwise.
    """
    if "/" in resource:
        return resource in available_resources
    else:
        return any(item.endswith(f"/{resource}") for item in available_resources)


def prompt_choice(question: str, options: list[str]) -> str:
    """
    Displays a question along with a list of allowed options and continues to prompt
    until the user provides a valid answer.

    Args:
        question (str): The question to display.
        options (list[str]): A list of allowed answers.

    Returns:
        str: The user's answer from the list of allowed options.
    """
    options_str = "/".join(options)
    while True:
        answer = (
            typer.prompt(typer.style(f"{question} ({options_str})", fg="bright_blue"))
            .strip()
            .lower()
        )
        if answer in options:
            return answer
        typer.echo(
            typer.style(
                f"Invalid option. Please choose one of: {options_str}.", fg="bright_red"
            )
        )


def select_resource_by_index(value: str, available_resources: list[str]) -> str | None:
    """
    Attempts to select and return a resource from the available_resources list
    based on the provided index (as a string).

    The index is expected to be a 1-based string (e.g., "1" selects the first element).
    If the value is not a valid integer, is out of range, or less than 1, None is returned.

    Args:
        value (str): The index of the desired resource as a string (1-based index).
        available_resources (list[str]): The list of available resource names.

    Returns:
        str | None: The selected resource if the index is valid, otherwise None.
    """
    try:
        index = int(value) - 1
        if index < 0:
            return None
        return available_resources[index]
    except (ValueError, IndexError):
        return None


def get_resource_name(
    available_resources: list[str],
    resource_type: Literal["template", "app"],
    resource_name: str | None = None,
) -> str:
    """
    Validate the provided resource name or prompt the user to select one.

    Args:
        available_resources (list[str]): A list of valid resource names.
        resource_name (str | None): The resource name provided by the user. If None,
            the user will be prompted to select a resource.
        resource_type: (Literal["template", "app"]): Type of resources to be validated. Supported:
            - template (default)
            - app

    Returns:
        str: The validated resource name selected or provided by the user.

    Raises:
        typer.Exit: If the provided or selected resource name is not found in the available_resources list.
    """
    if resource_name is None:
        resource_name = typer.prompt(
            prepare_resources_prompt(
                available_resources, ask=True, resource_type=resource_type
            )
        )

    selected = select_resource_by_index(resource_name, available_resources)
    if selected is not None:
        return selected

    if resource_name.isnumeric():
        typer.echo(
            typer.style(
                f"!!! Cannot find {resource_type} numbered {resource_name}",
                fg="bright_red",
                bold=True,
            )
        )
        raise typer.Exit(code=1)

    if not resource_exists(resource_name, available_resources):
        typer.echo(
            typer.style(
                f"!!! Cannot find {resource_type} '{resource_name}'. Available {resource_type}s: {available_resources}",
                fg="bright_red",
                bold=True,
            )
        )
        raise typer.Exit(code=1)
    return resource_name


def get_directory(selected_resource: str, directory: str | None) -> str:
    """
    Retrieve the target directory for resource creation.

    Args:
        selected_resource (str): The full path or name of the selected resource. Used to suggest a default folder name.
        directory (str | None): The target directory name. If None, the user will be prompted.

    Returns:
        str: The directory name to be used as the target folder.
    """
    shorted_selected_resource = selected_resource.split("/")[-1]
    if directory is None:
        directory = typer.prompt(
            typer.style(
                f"The name of the folder to create (press Enter to use default name: '{shorted_selected_resource}')",
                fg="bright_blue",
            ),
            default=shorted_selected_resource,
            show_default=False,
        )
    return directory


def load_question_payload() -> Any:
    """
    Load the question payload from the file specified in the configuration.

    Returns:
        Any: The JSON-decoded payload from the file.

    Raises:
        typer.Exit: If the payload path is not defined or if loading the JSON fails.
    """
    payload_path = get_payload_path()
    if payload_path:
        try:
            with open(payload_path, "r") as f:
                return json.load(f)
        except Exception as e:
            typer.echo(
                typer.style(
                    f"Failed to load payload from {payload_path}: {e}",
                    fg="bright_red",
                    bold=True,
                )
            )
            raise typer.Exit(code=1)
    else:
        typer.echo(
            typer.style(
                "Payload not provided. Please specify the `question` parameter or define the `payload_path` in the `[cli.options]` section of `config.toml` file.",
                fg="bright_red",
                bold=True,
            )
        )
        raise typer.Exit(code=1)


def get_ai_service_dashboard_url(client: APIClient, deployment_id: str) -> str | None:
    """
    Generate the AI service dashboard URL for a given deployment.

    Args:
        client (APIClient): The APIClient instance.
        deployment_id (str): The unique identifier for the deployment.

    Returns:
        str | None: The formatted AI service dashboard URL. If any component is
                    missing, it returns None.
    """
    space_id = get_deployment_space_id()
    deployment_url = get_deployment_url()

    if deployment_url is None:
        return None

    platform_url = (
        client.PLATFORM_URLS_MAP[deployment_url]
        if is_cloud_environment(deployment_url)
        else deployment_url
    )

    return f"{platform_url}/ml-runtime/deployments/{deployment_id}?space_id={space_id}".replace(
        "api.", ""
    )


def prompt_and_get_deployment_id() -> str | None:
    typer.echo(
        typer.style(
            "The `deployment_id`, as specified in the `deployment` section of your config.toml file, is employed.",
            fg="bright_green",
            bold=True,
        )
    )

    return get_deployment_id()


def get_package_name(project_path: Path) -> str:
    """
    Extract the package name from a pyproject.toml or package.json file.

    Args:
        project_path (Path): Path to the pyproject.toml or package.json file.

    Returns:
        str: A str containing the package name.

    Raises:
        ValueError: If either the package name is missing in the file.
    """
    if str(project_path).endswith("pyproject.toml"):
        with open(project_path, "rb") as f:
            pyproject_data = tomli.load(f)
        tool_poetry = pyproject_data.get("tool", {}).get("poetry", {})
        package_name = tool_poetry.get("name")
        if not package_name:
            raise ValueError("Package name is missing in pyproject.toml.")
        return package_name
    elif str(project_path).endswith("package.json"):
        folder_path = Path(str(project_path).split("/package.json")[0])
        result = subprocess.run(
            ["npm", "pkg", "get", "name"],
            capture_output=True,
            text=True,
            cwd=folder_path,
        )
        if result.returncode == 0:
            package_name = result.stdout.strip().replace('"', "").replace("name: ", "")
            print(package_name)
            if package_name != "{}":
                return package_name
            else:
                raise ValueError("Package name is missing in project.json.")
        else:
            raise ValueError("Failed to retrieve package name")
    else:
        raise Exception


def check_poetry_cli() -> bool:
    """Checks if the 'poetry' command-line tool is available (e.g., installed via Homebrew)."""
    try:
        subprocess.check_output(["poetry", "--version"], stderr=subprocess.STDOUT)
        return True
    except (subprocess.CalledProcessError, FileNotFoundError):
        return False


def check_poetry_python_package() -> bool:
    """Checks if Poetry is installed as a Python package."""
    try:
        import poetry  # type: ignore[import]  # noqa: F401

        return True
    except ImportError:
        return False


def is_cloud_environment(url: str) -> bool:
    """Returns True if the given URL points to an IBM Cloud environment, else False"""
    if "://" not in url:
        url = "https://" + url
    hostname = urllib.parse.urlparse(url).hostname or ""
    return hostname.lower().endswith("cloud.ibm.com")
