"""Package command group"""

import json
import os
from getpass import getpass
from pathlib import Path
from typing import Literal

import click
from click.shell_completion import CompletionItem
from cmem_client.client import Client
from cmem_client.components.marketplace import Marketplace
from cmem_client.models.credentials import PasswordCredentials
from cmem_client.repositories.marketplace_packages import (
    MarketplacePackagesDeleteConfig,
    MarketplacePackagesExportConfig,
    MarketplacePackagesImportConfig,
    MarketplacePackagesRepository,
)
from eccenca_marketplace_client.exceptions import PackageContentError
from eccenca_marketplace_client.package_version import PackageVersion
from pydantic import SecretStr
from pydantic_extra_types.semantic_version import SemanticVersion

from cmem_cmemc import completion
from cmem_cmemc.command import CmemcCommand
from cmem_cmemc.command_group import CmemcGroup
from cmem_cmemc.completion import suppress_completion_errors
from cmem_cmemc.context import ApplicationContext
from cmem_cmemc.exceptions import CmemcError
from cmem_cmemc.object_list import (
    DirectValuePropertyFilter,
    ObjectList,
    compare_regex,
    compare_str_equality,
)
from cmem_cmemc.parameter_types.path import ClickSmartPath
from cmem_cmemc.utils import struct_to_table

DEFAULT_MARKETPLACE = "https://eccenca.market"


def get_installed_packages_list(ctx: click.Context) -> list[dict]:
    """Get the list of installed packages"""
    ApplicationContext.set_connection_from_params(ctx.find_root().params)
    client = Client.from_cmempy()
    packages: MarketplacePackagesRepository = client.marketplace_packages
    return [
        {
            "id": package_.package_version.manifest.package_id,
            "type": str(package_.package_version.manifest.package_type).replace(
                "PackageTypes.", ""
            ),
            "version": str(package_.package_version.manifest.package_version),
            "name": package_.package_version.manifest.metadata.name,
            "description": package_.package_version.manifest.metadata.description,
        }
        for package_ in packages.values()
    ]


installed_packages_list = ObjectList(
    name="installed packages",
    get_objects=get_installed_packages_list,
    filters=[
        DirectValuePropertyFilter(
            name="type",
            description="Filter list by package type.",
            property_key="type",
        ),
        DirectValuePropertyFilter(
            name="name",
            description="Filter list by regex matching the package name.",
            property_key="name",
            compare=compare_regex,
            fixed_completion=[],
        ),
        DirectValuePropertyFilter(
            name="id",
            description="Filter list by package ID.",
            property_key="id",
            compare=compare_str_equality,
        ),
    ],
)


@suppress_completion_errors
def _complete_installed_package_ids(
    ctx: click.Context,
    param: click.Argument,  # noqa: ARG001
    incomplete: str,
) -> list[CompletionItem]:
    """Prepare a list of IDs of installed packages."""
    ApplicationContext.set_connection_from_params(ctx.find_root().params)
    candidates = [
        (_["id"], f"{_['version']}: {_['name']}")
        for _ in installed_packages_list.apply_filters(ctx=ctx)
    ]
    return completion.finalize_completion(candidates=candidates, incomplete=incomplete)


@suppress_completion_errors
def _complete_marketplace_package_ids(
    ctx: click.Context,
    param: click.Argument,  # noqa: ARG001
    incomplete: str,
) -> list[CompletionItem]:
    """Prepare a list of IDs of available packages."""
    ApplicationContext.set_connection_from_params(ctx.find_root().params)
    client = Client.from_cmempy()
    marketplace = Marketplace(client=client)
    marketplace.marketplace_url = ctx.params.get("marketplace_url", DEFAULT_MARKETPLACE)
    candidates = [(_.id, f"{_.name}") for _ in marketplace.get_available_packages()]
    return completion.finalize_completion(candidates=candidates, incomplete=incomplete)


@click.command(cls=CmemcCommand, name="inspect")
@click.argument(
    "PACKAGE_PATH",
    required=True,
    type=ClickSmartPath(
        allow_dash=False,
        dir_okay=True,
        readable=True,
        exists=True,
        remote_okay=True,
    ),
)
@click.option(
    "--key",
    "key",
    help="Get a specific key only from the manifest.",
)
@click.option("--raw", is_flag=True, help="Outputs raw JSON.")
@click.pass_obj
def inspect_command(app: ApplicationContext, package_path: Path, key: str, raw: str) -> None:
    """Inspect the manifest of a package."""
    path = Path(package_path)
    package_version = (
        PackageVersion.from_directory(path, validate_files=False)
        if path.is_dir()
        else PackageVersion.from_archive(path)
    )
    manifest = package_version.manifest
    manifest_data = json.loads(manifest.model_dump_json(indent=2))
    if raw:
        app.echo_info_json(manifest_data)
        return
    if key:
        table = [
            line
            for line in struct_to_table(manifest_data)
            if line[0].startswith(key) or key == "all"
        ]
        if len(table) == 1:
            app.echo_info(table[0][1])
            return
        if len(table) == 0:
            raise CmemcError(f"No values for key '{key}'.")
        app.echo_info_table(table, headers=["Key", "Value"], sort_column=0)
        return
    table = struct_to_table(manifest_data)
    app.echo_info_table(
        table,
        headers=["Key", "Value"],
        sort_column=0,
        caption=f"Manifest of package '{manifest.package_id}' in"
        f" {path.name} (v{manifest.package_version})",
    )


@click.command(cls=CmemcCommand, name="list")
@click.option(
    "--filter",
    "filter_",
    multiple=True,
    type=(str, str),
    shell_complete=installed_packages_list.complete_values,
    help=installed_packages_list.get_filter_help_text(),
)
@click.option(
    "--id-only",
    is_flag=True,
    help="Lists only package IDs. This is useful for piping the IDs into other commands.",
)
@click.option("--raw", is_flag=True, help="Outputs raw JSON.")
@click.pass_context
def list_command(
    ctx: click.Context, filter_: tuple[tuple[str, str]], id_only: bool, raw: bool
) -> None:
    """List installed packages."""
    app: ApplicationContext = ctx.obj
    data = installed_packages_list.apply_filters(ctx=ctx, filter_=filter_)
    if id_only:
        for _ in sorted(data, key=lambda _: _["id"]):
            app.echo_info(_["id"])
        return
    if raw:
        app.echo_info_json(data)
        return
    table = [
        (
            _["id"],
            _["version"],
            _["type"],
            _["name"],
        )
        for _ in data
    ]
    app.echo_info_table(
        table,
        headers=["ID", "Version", "Type", "Name"],
        sort_column=0,
        empty_table_message="No installed packages found. "
        "You can use the `package install` command to install packages.",
    )


@click.command(cls=CmemcCommand, name="install")
@click.argument(
    "PACKAGE_ID",
    required=False,
    type=click.STRING,
    shell_complete=_complete_marketplace_package_ids,
)
@click.option(
    "--input",
    "-i",
    "input_path",
    type=ClickSmartPath(allow_dash=False, dir_okay=True, file_okay=True, readable=True),
    help="Install a package from a package archive (.cpa) or directory.",
)
@click.option(
    "--replace", is_flag=True, help="Replace (overwrite) existing package version, if present."
)
@click.option("--no-cache", is_flag=True, help="Disable using cached package versions.")
@click.option(
    "--ignore-lock",
    is_flag=True,
    help="Ignore lock file for operation. This can be dangerous, use with care.",
)
@click.option(
    "--marketplace-url",
    type=str,
    help="Base URL of the Marketplace - uses environment variable ECCENCA_MARKETPLACE_URL "
    "if available.",
    default=DEFAULT_MARKETPLACE,
    envvar="ECCENCA_MARKETPLACE_URL",
    show_default=True,
)
@click.pass_obj
def install_command(  # noqa: PLR0913
    app: ApplicationContext,
    package_id: str,
    replace: bool,
    input_path: str,
    no_cache: bool,
    ignore_lock: bool,
    marketplace_url: str,
) -> None:
    """Install packages.

    This command installs a package either from the marketplace or from local package
    archives (.cpa) or directories.
    """
    if not package_id and not input_path:
        raise CmemcError(
            "Nothing to install. Either specify a package ID from the marketplace, "
            "or use the `--input` option to install a local package."
        )
    if package_id and input_path:
        raise CmemcError(
            "You can not install from the marketplace and local files at the same time."
        )

    client = app.client
    client.marketplace.marketplace_url = marketplace_url

    if input_path:
        package_path = Path(input_path)
        package_version = (
            PackageVersion.from_directory(package_path, validate_files=False)
            if package_path.is_dir()
            else PackageVersion.from_archive(package_path)
        )
        package_id = package_version.manifest.package_id
        app.echo_info(f"Installing package '{package_id}' from '{input_path}' ... ", nl=False)
        client.marketplace_packages.import_item(
            path=package_path,
            replace=replace,
            configuration=MarketplacePackagesImportConfig(
                install_from_marketplace=False, use_cache=not no_cache, ignore_lock=ignore_lock
            ),
        )
    else:
        app.echo_info(f"Installing package '{package_id}' from marketplace ... ", nl=False)
        client.marketplace_packages.import_item(
            key=package_id,
            replace=replace,
            configuration=MarketplacePackagesImportConfig(
                use_cache=not no_cache, ignore_lock=ignore_lock
            ),
        )
    app.echo_success("done")


def _filter_installed_packages(
    ctx: click.Context, package_id: str | None, filter_: tuple[tuple[str, str]], all_: bool
) -> list[dict]:
    """Filter installed packages."""
    if package_id is None and not filter_ and not all_:
        raise click.UsageError("Either provide a package ID or a filter, or use the --all flag.")

    if all_:
        packages_to_work_on = installed_packages_list.apply_filters(ctx=ctx)
    else:
        filter_to_apply = list(filter_) if filter_ else []
        if package_id:
            filter_to_apply.append(("id", package_id))
        packages_to_work_on = installed_packages_list.apply_filters(
            ctx=ctx, filter_=filter_to_apply
        )

    if not packages_to_work_on and package_id:
        raise CmemcError(f"Package '{package_id}' is not installed.")
    return packages_to_work_on


@click.command(cls=CmemcCommand, name="uninstall")
@click.argument(
    "PACKAGE_ID",
    type=click.STRING,
    shell_complete=_complete_installed_package_ids,
    required=False,
)
@click.option(
    "--ignore-lock",
    is_flag=True,
    help="Ignore lock file for operation. This can be dangerous, use with care.",
)
@click.option(
    "--filter",
    "filter_",
    multiple=True,
    type=(str, str),
    shell_complete=installed_packages_list.complete_values,
    help=installed_packages_list.get_filter_help_text(),
)
@click.option(
    "-a",
    "--all",
    "all_",
    is_flag=True,
    help="Uninstall all packages. This is a dangerous option, so use it with care.",
)
@click.pass_context
def uninstall_command(
    ctx: click.Context,
    package_id: str | None,
    ignore_lock: bool,
    filter_: tuple[tuple[str, str]],
    all_: bool,
) -> None:
    """Uninstall installed packages."""
    app: ApplicationContext = ctx.obj
    packages_to_uninstall = _filter_installed_packages(
        ctx=ctx, package_id=package_id, filter_=filter_, all_=all_
    )
    packages = app.client.marketplace_packages
    for _ in packages_to_uninstall:
        id_to_uninstall = _["id"]
        app.echo_info(f"Uninstalling package {id_to_uninstall} ... ", nl=False)
        if all_:
            configuration = MarketplacePackagesDeleteConfig(
                ignore_lock=ignore_lock, ignore_dependencies=True
            )
        else:
            configuration = MarketplacePackagesDeleteConfig(ignore_lock=ignore_lock)
        packages.delete_item(
            key=id_to_uninstall,
            configuration=configuration,
        )
        app.echo_success("done")


@click.command(cls=CmemcCommand, name="export")
@click.argument(
    "PACKAGE_ID",
    type=click.STRING,
    shell_complete=_complete_installed_package_ids,
    required=False,
)
@click.option(
    "--mime_type",
    type=click.Choice(["text/turtle", "text/turtle+pretty"]),
    default="text/turtle+pretty",
    help="Choose the MIME type for graphs when exporting packages.",
    show_default=True,
)
@click.option(
    "--filter",
    "filter_",
    multiple=True,
    type=(str, str),
    shell_complete=installed_packages_list.complete_values,
    help=installed_packages_list.get_filter_help_text(),
)
@click.option(
    "-a",
    "--all",
    "all_",
    is_flag=True,
    help="Export all installed packages.",
)
@click.option(
    "--output-dir",
    default=".",
    show_default=True,
    type=ClickSmartPath(writable=True, file_okay=False),
    help="Create package directories in this base directory.",
)
@click.option("--replace", is_flag=True, help="Replace (overwrite) existing files, if present.")
@click.pass_context
def export_command(  # noqa: PLR0913
    ctx: click.Context,
    package_id: str | None,
    mime_type: Literal["turtle", "pretty-turtle"],
    filter_: tuple[tuple[str, str]],
    all_: bool,
    output_dir: str,
    replace: bool,
) -> None:
    """Export installed packages to package directories."""
    app: ApplicationContext = ctx.obj
    packages_to_export = _filter_installed_packages(
        ctx=ctx, package_id=package_id, filter_=filter_, all_=all_
    )
    packages = app.client.marketplace_packages
    serialization_id = "pretty-turtle" if "pretty" in mime_type else "turtle"
    for _ in packages_to_export:
        id_to_export = _["id"]
        app.echo_info(f"Exporting package `{id_to_export}` ... ", nl=False)
        configuration = MarketplacePackagesExportConfig(
            export_as_zip=False, export_graph_serialization=serialization_id
        )
        packages.export_item(
            id_to_export,
            path=(Path(output_dir) / id_to_export).absolute(),
            replace=replace,
            configuration=configuration,
        )
        app.echo_success("done")


@click.command(cls=CmemcCommand, name="build")
@click.argument(
    "PACKAGE_DIRECTORY",
    required=True,
    type=ClickSmartPath(
        allow_dash=False,
        dir_okay=True,
        file_okay=False,
        readable=True,
        exists=True,
        remote_okay=False,
    ),
)
@click.option("--version", help="Set the package version.")
@click.option("--replace", is_flag=True, help="Replace package archive, if present.")
@click.option(
    "--output-dir",
    type=ClickSmartPath(writable=True, file_okay=False, dir_okay=True),
    help="Create the package archive in a specific directory.",
    default=".",
    show_default=True,
)
@click.pass_obj
def build_command(
    app: ApplicationContext, package_directory: str, version: str, replace: bool, output_dir: str
) -> None:
    """Build a package archive from a package directory.

    This command processes a package directory, validates its content including the manifest,
    and creates a versioned Corporate Memory package archive (.cpa) with the following naming
    convention: {package_id}-v{version}.cpa

    Package archives can be published to the marketplace using the `package publish` command.
    """
    package_path = Path(package_directory)
    package_version = PackageVersion.from_directory(package_path, validate_files=False)
    if version:
        version = version.removeprefix("v")
        package_version.manifest.package_version = SemanticVersion.parse(version)
    version_str = str(package_version.manifest.package_version)
    package_id = package_version.manifest.package_id

    if output_dir is not None:
        cpa_file = Path(
            os.path.normpath(str(Path(output_dir) / f"{package_id}-v{version_str}.cpa"))
        )
    else:
        cpa_file = Path(f"{package_id}-v{version_str}.cpa")

    if version_str.endswith("dirty"):
        app.echo_warning(
            "Dirty Repository: Your version string ends with 'dirty'. "
            "This indicates an unclean repository."
        )
    if version_str == "0.0.0":
        raise CmemcError(
            "Invalid Version 0.0.0: "
            "Use the --version option to override this, or change the manifest."
        )
    if cpa_file.exists() and not replace:
        raise CmemcError(
            f"Package archive `{cpa_file}` already exists. Use `--replace` to overwrite."
        )
    app.echo_info(f"Building package archive `{cpa_file.name}` ... ", nl=False)

    try:
        package_version.validate_file_paths(package_version.manifest, package_path)
        package_version.validate_files(package_version.manifest, package_path)
        Path(cpa_file).parent.mkdir(exist_ok=True, parents=True)
        if cpa_file.exists():
            cpa_file.unlink()
        package_version.build_archive(archive=cpa_file)
    except PackageContentError:
        if cpa_file.exists():
            cpa_file.unlink()
        raise

    app.echo_success("done")


@click.command(cls=CmemcCommand, name="publish")
@click.argument(
    "PACKAGE_ARCHIVE",
    required=True,
    type=ClickSmartPath(
        allow_dash=False,
        dir_okay=False,
        readable=True,
        exists=True,
        remote_okay=True,
    ),
)
@click.option(
    "--timeout",
    type=click.INT,
    help="Timeout for marketplace requests.",
)
@click.option(
    "--marketplace-url",
    type=str,
    help="Base URL of the Marketplace - uses environment variable ECCENCA_MARKETPLACE_URL "
    "if available.",
    default=DEFAULT_MARKETPLACE,
    envvar="ECCENCA_MARKETPLACE_URL",
    show_default=True,
)
@click.option(
    "--marketplace-account",
    type=str,
    help="Marketplace account - uses environment variable ECCENCA_MARKETPLACE_ACCOUNT "
    "if available.",
    default=None,
    envvar="ECCENCA_MARKETPLACE_ACCOUNT",
)
@click.option(
    "--marketplace-password",
    type=str,
    help="Marketplace password - uses environment variable ECCENCA_MARKETPLACE_PASSWORD "
    "if available.",
    default=None,
    envvar="ECCENCA_MARKETPLACE_PASSWORD",
)
@click.pass_obj
def publish_command(  # noqa: PLR0913
    app: ApplicationContext,
    package_archive: str,
    marketplace_url: str,
    marketplace_account: str | None,
    marketplace_password: str | None,
    timeout: int | None = None,
) -> None:
    """Publish a package archive to the marketplace server."""
    package_path = Path(package_archive)

    package_version = PackageVersion.from_archive(package_path)
    package_id = package_version.manifest.package_id

    app.echo_info(f"Package: {package_id} v{package_version.manifest.package_version}")
    app.echo_info(f"Marketplace URL: {marketplace_url}")
    app.echo_info("Marketplace Account: ", nl=False)
    if marketplace_account is None:
        marketplace_account = input("")
    else:
        app.echo_info(marketplace_account)
    app.echo_info("Marketplace Password: ", nl=False)
    if marketplace_password is None:
        marketplace_password = getpass(prompt="")
    else:
        app.echo_info("***")

    app.echo_info(f"Publishing package `{package_id}` ... ", nl=False)

    password_credentials = PasswordCredentials(
        username=marketplace_account, password=SecretStr(marketplace_password)
    )

    marketplace = app.client.marketplace
    marketplace.marketplace_url = marketplace_url
    marketplace.credentials = password_credentials

    if timeout:
        marketplace.timeout = timeout

    marketplace.upload_package(package_id, package_path)

    app.echo_success("done")


@click.command(cls=CmemcCommand, name="search")
@click.argument(
    "SEARCH_TERMS",
    nargs=-1,
    type=click.STRING,
)
@click.option("--raw", is_flag=True, help="Outputs raw JSON.")
@click.option(
    "--marketplace-url",
    type=str,
    help="Base URL of the Marketplace - uses environment variable ECCENCA_MARKETPLACE_URL "
    "if available.",
    default=DEFAULT_MARKETPLACE,
    envvar="ECCENCA_MARKETPLACE_URL",
    show_default=True,
)
@click.pass_obj
def search_command(
    app: ApplicationContext, search_terms: tuple[str], raw: bool, marketplace_url: str
) -> None:
    """Search for available packages with a given search text."""
    marketplace = app.client.marketplace
    marketplace.marketplace_url = marketplace_url
    packages = marketplace.get_available_packages()

    available_packages = []
    search_terms_lower = [term.lower() for term in search_terms]

    if search_terms == "":
        available_packages = packages
    else:
        for package in packages:
            searchable_text = (
                f"{package.id.lower()} {package.name.lower()} "
                f"{package.description.lower()} {package.type.value.lower()}"
            )
            if all(term in searchable_text for term in search_terms_lower):
                available_packages.append(package)

    if raw:
        package_data = [
            json.loads(metadata.model_dump_json(indent=2)) for metadata in available_packages
        ]
        app.echo_info_json(package_data)
        return

    table = [
        (
            _.id,
            _.name,
            _.description,
            _.type.value,
        )
        for _ in available_packages
    ]
    app.echo_info_table(
        table,
        headers=["ID", "Name", "Description", "Type"],
        sort_column=0,
        empty_table_message="No available packages found.",
        caption=f"{len(available_packages)} package{'' if len(available_packages) == 1 else 's'} "
        f"found on {marketplace.marketplace_url}",
    )


@click.group(cls=CmemcGroup, hidden=True)
def package_group() -> CmemcGroup:  # type: ignore[empty-body]
    """List, (un)install, export, create, or inspect packages."""


package_group.add_command(inspect_command)
package_group.add_command(list_command)
package_group.add_command(install_command)
package_group.add_command(uninstall_command)
package_group.add_command(export_command)
package_group.add_command(build_command)
package_group.add_command(publish_command)
package_group.add_command(search_command)
