from collections.abc import Hashable
from graphlib import TopologicalSorter
from pathlib import Path
from typing import overload

from cognite.client.exceptions import CogniteAPIError, CogniteDuplicatedError
from cognite.client.utils._identifier import T_ID
from rich import print
from rich.markup import escape
from rich.panel import Panel

from cognite_toolkit._cdf_tk.client import ToolkitClient
from cognite_toolkit._cdf_tk.commands._base import ToolkitCommand
from cognite_toolkit._cdf_tk.commands.clean import CleanCommand
from cognite_toolkit._cdf_tk.constants import (
    _RUNNING_IN_BROWSER,
    BUILD_ENVIRONMENT_FILE,
    HINT_LEAD_TEXT,
)
from cognite_toolkit._cdf_tk.cruds import (
    DataCRUD,
    Loader,
    RawDatabaseCRUD,
    ResourceContainerCRUD,
    ResourceCRUD,
    ResourceWorker,
)
from cognite_toolkit._cdf_tk.cruds._worker import CategorizedResources
from cognite_toolkit._cdf_tk.data_classes import (
    BuildEnvironment,
    DatapointDeployResult,
    DeployResult,
    DeployResults,
    ResourceContainerDeployResult,
    ResourceDeployResult,
    UploadDeployResult,
)
from cognite_toolkit._cdf_tk.data_classes._module_directories import ReadModule
from cognite_toolkit._cdf_tk.exceptions import (
    ResourceCreationError,
    ResourceUpdateError,
    ToolkitDeployResourceError,
    ToolkitFileNotFoundError,
    ToolkitNotADirectoryError,
)
from cognite_toolkit._cdf_tk.protocols import (
    T_ResourceRequest,
    T_ResourceRequestList,
    T_ResourceResponse,
    T_ResourceResponseList,
)
from cognite_toolkit._cdf_tk.tk_warnings import EnvironmentVariableMissingWarning
from cognite_toolkit._cdf_tk.tk_warnings.base import WarningList, catch_warnings
from cognite_toolkit._cdf_tk.tk_warnings.other import (
    LowSeverityWarning,
    ToolkitDependenciesIncludedWarning,
)
from cognite_toolkit._cdf_tk.utils import humanize_collection, read_yaml_file
from cognite_toolkit._cdf_tk.utils.auth import EnvironmentVariables

from ._utils import _print_ids_or_length


class DeployCommand(ToolkitCommand):
    def __init__(self, print_warning: bool = True, skip_tracking: bool = False, silent: bool = False) -> None:
        super().__init__(print_warning, skip_tracking, silent)
        self._clean_command = CleanCommand(print_warning, skip_tracking=True)

    def deploy_build_directory(
        self,
        env_vars: EnvironmentVariables,
        build_dir: Path,
        build_env_name: str | None,
        dry_run: bool,
        drop: bool,
        drop_data: bool,
        force_update: bool,
        include: list[str] | None,
        verbose: bool,
    ) -> None:
        include = self._clean_command.validate_include(include)

        build = self._load_build(build_dir, build_env_name)

        client = env_vars.get_client(build.is_strict_validation)

        self._additional_tracking_info.project = client.config.project
        self._additional_tracking_info.cluster = client.config.cdf_cluster

        if not dry_run:
            for module in build.read_modules:
                if module.module_id:
                    self._additional_tracking_info.module_ids.add(module.module_id)
                if module.package_id:
                    self._additional_tracking_info.package_ids.add(module.package_id)

        selected_loaders = self._clean_command.get_selected_loaders(build_dir, build.read_resource_folders, include)
        ordered_loaders = self._order_loaders(selected_loaders, build_dir)
        self._start_message(build_dir, dry_run, env_vars)
        results = DeployResults([], "deploy", dry_run=dry_run)
        if drop or drop_data:
            self.clean_all_resources(
                client, results, build, build_dir, drop, drop_data, dry_run, env_vars, ordered_loaders, verbose
            )

        self.deploy_all_resources(
            client=client,
            build_dir=build_dir,
            env_vars=env_vars,
            results=results,
            dry_run=dry_run,
            has_dropped=drop,
            has_dropped_data=drop_data,
            force_update=force_update,
            ordered_loaders=ordered_loaders,
            build=build,
            verbose=verbose,
        )

        if results.has_counts:
            print(results.counts_table())
        if results.has_uploads:
            print(results.uploads_table())

    def _load_build(self, build_dir: Path, build_env_name: str | None) -> BuildEnvironment:
        if not build_dir.is_dir():
            raise ToolkitNotADirectoryError(
                "The build directory does not exists. Did you forget to run `cdf build` first?"
            )
        build_environment_file_path = build_dir / BUILD_ENVIRONMENT_FILE
        if not build_environment_file_path.is_file():
            raise ToolkitFileNotFoundError(
                f"Could not find build environment file '{BUILD_ENVIRONMENT_FILE}' in '{build_dir}'. "
                "Did you forget to run `cdf build` first?"
            )
        build = BuildEnvironment.load(read_yaml_file(build_environment_file_path), build_env_name, "deploy")
        build.set_environment_variables()
        errors = build.check_source_files_changed()
        for error in errors:
            self.warn(error)
        if errors:
            raise ToolkitDeployResourceError(
                "One or more source files have been modified since the last build. Please rebuild the project."
            )
        return build

    def _order_loaders(
        self, selected_loaders: dict[type[Loader], frozenset[type[Loader]]], build_dir: Path
    ) -> list[type[Loader]]:
        """The loaders must be topological sorted to ensure that dependencies are deployed in the correct order."""
        ordered_loaders: list[type[Loader]] = []
        should_include: list[type[Loader]] = []
        # The topological sort can include loaders that are not selected, so we need to check for that.
        for loader_cls in TopologicalSorter(selected_loaders).static_order():
            if loader_cls in selected_loaders:
                ordered_loaders.append(loader_cls)
            elif (build_dir / loader_cls.folder_name).is_dir():
                should_include.append(loader_cls)
            # Otherwise, it is not in the build directory and not selected, so we skip it.
            # There should be a warning in the build step if it is missing.
        if should_include:
            self.warn(ToolkitDependenciesIncludedWarning(list({item.folder_name for item in should_include})))
        return ordered_loaders

    @staticmethod
    def _start_message(build_dir: Path, dry_run: bool, env_vars: EnvironmentVariables) -> None:
        environment_vars = ""
        if not _RUNNING_IN_BROWSER:
            environment_vars = f"\n\nConnected to {env_vars.as_string()}"
        verb = "Checking" if dry_run else "Deploying"
        print(
            Panel(
                f"[bold]{verb}[/]resource files from {build_dir} directory.{environment_vars}",
                expand=False,
            )
        )

    def clean_all_resources(
        self,
        client: ToolkitClient,
        results: DeployResults,
        build: BuildEnvironment,
        build_dir: Path,
        drop: bool,
        drop_data: bool,
        dry_run: bool,
        env_vars: EnvironmentVariables,
        ordered_loaders: list[type[Loader]],
        verbose: bool,
    ) -> None:
        # Drop has to be done in the reverse order of deploy.
        if drop and drop_data:
            print(Panel("[bold] Cleaning resources as --drop and --drop-data are passed[/]"))
        elif drop:
            print(Panel("[bold] Cleaning resources as --drop is passed[/]"))
        elif drop_data:
            print(Panel("[bold] Cleaning resources as --drop-data is passed[/]"))
        else:
            return None

        for loader_cls in reversed(ordered_loaders):
            if not issubclass(loader_cls, ResourceCRUD):
                continue
            loader: ResourceCRUD = loader_cls.create_loader(client, build_dir)
            result = self._clean_command.clean_resources(
                loader,
                env_vars=env_vars,
                read_modules=build.read_modules,
                drop=drop,
                dry_run=dry_run,
                drop_data=drop_data,
                verbose=verbose,
            )
            if result:
                results[result.name] = result
        print("[bold]...cleaning complete![/]")
        return None

    @overload
    def deploy_all_resources(
        self,
        client: ToolkitClient,
        build_dir: Path,
        env_vars: EnvironmentVariables,
        results: None = None,
        dry_run: bool = False,
        has_dropped: bool = False,
        has_dropped_data: bool = False,
        force_update: bool = False,
        ordered_loaders: list[type[Loader]] | None = None,
        build: BuildEnvironment | None = None,
        verbose: bool = False,
    ) -> DeployResults: ...

    @overload
    def deploy_all_resources(
        self,
        client: ToolkitClient,
        build_dir: Path,
        env_vars: EnvironmentVariables,
        results: DeployResults,
        dry_run: bool = False,
        has_dropped: bool = False,
        has_dropped_data: bool = False,
        force_update: bool = False,
        ordered_loaders: list[type[Loader]] | None = None,
        build: BuildEnvironment | None = None,
        verbose: bool = False,
    ) -> None: ...

    def deploy_all_resources(
        self,
        client: ToolkitClient,
        build_dir: Path,
        env_vars: EnvironmentVariables,
        results: DeployResults | None = None,
        dry_run: bool = False,
        has_dropped: bool = False,
        has_dropped_data: bool = False,
        force_update: bool = False,
        ordered_loaders: list[type[Loader]] | None = None,
        build: BuildEnvironment | None = None,
        verbose: bool = False,
    ) -> DeployResults | None:
        """Deploy all resources in the build directory.

        Args:
            client: The Cognite client to use for deployment.
            build_dir: The directory containing the built resource files.
            env_vars: The environment variables to use for variable replacement in the resource files.
            results: An optional DeployResults object to store the results of the deployment. If None, a new object will be created and returned.
            dry_run: If True, performs a dry run without actually deploying the resources.
            has_dropped: If True, indicates that resources have been dropped prior to deployment.
            has_dropped_data: If True, indicates that data resources have been dropped prior to deployment.
            force_update: If True, forces an update of resources even if they are unchanged.
            ordered_loaders: An optional list of loader classes to use for deployment. If None, all loaders will be used in the order determined by dependencies.
            build: An optional BuildEnvironment object containing information about the build. If None, data uploads will be skipped.
            verbose: If True, prints detailed information about the deployment process.

        Returns:
            A DeployResults object containing the results of the deployment if results is None, otherwise None.

        """
        if verbose:
            print(Panel("[bold]DEPLOYING resources...[/]"))

        if ordered_loaders is None:
            selected_loaders = self._clean_command.get_selected_loaders(build_dir, set(), None)
            ordered_loaders = self._order_loaders(selected_loaders, build_dir)
        read_modules: list[ReadModule] | None = None
        if build is not None:
            read_modules = build.read_modules
        output_results = DeployResults([], "deploy", dry_run=dry_run) if results is None else results
        for loader_cls in ordered_loaders:
            loader = loader_cls.create_loader(client, build_dir)
            resource_result: DeployResult | None
            if isinstance(loader, ResourceCRUD):
                resource_result = self.deploy_resource_type(
                    loader,
                    env_vars,
                    read_modules,
                    dry_run,
                    has_dropped,
                    has_dropped_data,
                    force_update,
                    verbose,
                )
            elif isinstance(loader, DataCRUD) and build is not None:
                resource_result = self.upload_data(loader, build, dry_run, verbose)
            elif isinstance(loader, DataCRUD) and build is None:
                self.warn(
                    LowSeverityWarning(
                        f"Skipping data upload for {loader.display_name} as it is not supported in this context."
                    )
                )
                continue
            else:
                raise ValueError(f"Unsupported loader type {type(loader)}.")

            if resource_result:
                output_results[resource_result.name] = resource_result
            if verbose:
                print("")  # Extra newline
        if results is None:
            return output_results
        return None

    def deploy_resource_type(
        self,
        loader: ResourceCRUD[
            T_ID, T_ResourceRequest, T_ResourceResponse, T_ResourceRequestList, T_ResourceResponseList
        ],
        env_vars: EnvironmentVariables,
        read_modules: list[ReadModule] | None = None,
        dry_run: bool = False,
        has_done_drop: bool = False,
        has_dropped_data: bool = False,
        force_update: bool = False,
        verbose: bool = False,
    ) -> ResourceDeployResult | None:
        worker = ResourceWorker(loader, "deploy")
        files = worker.load_files(read_modules=read_modules)
        if not files:
            return None

        with catch_warnings(EnvironmentVariableMissingWarning) as env_var_warnings:
            resources = worker.prepare_resources(
                files,
                environment_variables=env_vars.dump(include_os=True),
                is_dry_run=dry_run,
                force_update=force_update,
                verbose=verbose,
            )
        if env_var_warnings:
            print(str(env_var_warnings))
            self.warning_list.extend(env_var_warnings)

        # We are not counting to_delete as these are captured by to_create.
        # (to_delete is used for resources that does not support update and instead needs to be deleted and recreated)
        nr_of_items = len(resources.to_create) + len(resources.to_update) + len(resources.unchanged)
        if nr_of_items == 0:
            return ResourceDeployResult(name=loader.display_name)

        prefix = "Would deploy" if dry_run else "Deploying"
        print(f"[bold]{prefix} {nr_of_items} {loader.display_name} to CDF...[/]")

        # Moved here to avoid printing before the above message.
        if not isinstance(loader, RawDatabaseCRUD):
            for duplicate in worker.duplicates:
                self.warn(LowSeverityWarning(f"Skipping duplicate {loader.display_name} {duplicate}."))

        if dry_run:
            result = self.dry_run_deploy(
                resources,
                loader,
                has_done_drop,
                has_dropped_data,
            )
        else:
            result = self.actual_deploy(resources, loader, env_var_warnings)

        if verbose:
            self._verbose_print(resources, loader, dry_run)

        if isinstance(loader, ResourceContainerCRUD):
            return ResourceContainerDeployResult.from_resource_deploy_result(
                result,
                item_name=loader.item_name,
            )
        return result

    def actual_deploy(
        self,
        resources: CategorizedResources[T_ID, T_ResourceResponseList],
        loader: ResourceCRUD[
            T_ID, T_ResourceRequest, T_ResourceResponse, T_ResourceRequestList, T_ResourceResponseList
        ],
        env_var_warnings: WarningList | None = None,
    ) -> ResourceDeployResult:
        environment_variable_warning_by_id = {
            identifier: warning
            for warning in env_var_warnings or []
            if isinstance(warning, EnvironmentVariableMissingWarning)
            for identifier in warning.identifiers or []
        }
        nr_of_unchanged = len(resources.unchanged)
        nr_of_deleted, nr_of_created, nr_of_changed = 0, 0, 0
        if resources.to_delete:
            deleted = loader.delete(resources.to_delete)
            nr_of_deleted += deleted
        if resources.to_create:
            created = self._create_resources(resources.to_create, loader, environment_variable_warning_by_id)
            nr_of_created += created
        if resources.to_update:
            updated = self._update_resources(resources.to_update, loader, environment_variable_warning_by_id)
            nr_of_changed += updated
        return ResourceDeployResult(
            name=loader.display_name,
            created=nr_of_created,
            deleted=nr_of_deleted,
            changed=nr_of_changed,
            unchanged=nr_of_unchanged,
            total=nr_of_created + nr_of_deleted + nr_of_changed + nr_of_unchanged,
        )

    @staticmethod
    def dry_run_deploy(
        resources: CategorizedResources[T_ID, T_ResourceResponseList],
        loader: ResourceCRUD[
            T_ID, T_ResourceRequest, T_ResourceResponse, T_ResourceRequestList, T_ResourceResponseList
        ],
        has_done_drop: bool,
        has_dropped_data: bool,
    ) -> ResourceDeployResult:
        if (
            loader.support_drop
            and has_done_drop
            and (not isinstance(loader, ResourceContainerCRUD) or has_dropped_data)
        ):
            # Means the resources will be deleted and not left unchanged or changed
            for item in resources.unchanged:
                # We cannot use extents as LoadableNodes cannot be extended.
                resources.to_create.append(item)
            for item in resources.to_update:
                resources.to_create.append(item)
            resources.unchanged.clear()
            resources.to_update.clear()
        return ResourceDeployResult(
            name=loader.display_name,
            created=len(resources.to_create),
            deleted=len(resources.to_delete),
            changed=len(resources.to_update),
            unchanged=len(resources.unchanged),
            total=len(resources.to_create)
            + len(resources.to_delete)
            + len(resources.to_update)
            + len(resources.unchanged),
        )

    @staticmethod
    def _verbose_print(
        resources: CategorizedResources[T_ID, T_ResourceResponseList],
        loader: ResourceCRUD,
        dry_run: bool,
    ) -> None:
        print_outs = []
        prefix = "Would have " if dry_run else ""
        if resources.to_create:
            print_outs.append(f"{prefix}Created {_print_ids_or_length(loader.get_ids(resources.to_create), limit=20)}")
        if resources.to_update:
            print_outs.append(f"{prefix}Updated {_print_ids_or_length(loader.get_ids(resources.to_update), limit=20)}")
        if resources.unchanged:
            print_outs.append(
                f"{'Untouched' if dry_run else 'Unchanged'} {_print_ids_or_length(loader.get_ids(resources.unchanged), limit=5)}"
            )
        prefix_message = f" {loader.display_name}: "
        if len(print_outs) == 1:
            print(f"{prefix_message}{print_outs[0]}")
        elif len(print_outs) == 2:
            print(f"{prefix_message}{print_outs[0]} and {print_outs[1]}")
        else:
            print(f"{prefix_message}{', '.join(print_outs[:-1])} and {print_outs[-1]}")

    def _create_resources(
        self,
        resources: T_ResourceResponseList,
        loader: ResourceCRUD,
        environment_variable_warning_by_id: dict[Hashable, EnvironmentVariableMissingWarning],
    ) -> int:
        try:
            created = loader.create(resources)
        except CogniteAPIError as e:
            message = f"Failed to create resource(s). Error: {escape(str(e))!s}."
            if hint := self._environment_variable_hint(loader.get_ids(resources), environment_variable_warning_by_id):
                message += hint
            raise ResourceCreationError(message) from e
        except CogniteDuplicatedError as e:
            self.warn(
                LowSeverityWarning(
                    f"{len(e.duplicated)} out of {len(resources)} resource(s) already exist(s). {len(e.successful or [])} resource(s) created."
                )
            )
        else:
            return len(created) if created is not None else 0
        return 0

    def _update_resources(
        self,
        resources: T_ResourceResponseList,
        loader: ResourceCRUD,
        environment_variable_warning_by_id: dict[Hashable, EnvironmentVariableMissingWarning],
    ) -> int:
        try:
            updated = loader.update(resources)
        except CogniteAPIError as e:
            message = f"Failed to update resource(s). Error: {escape(str(e))}."
            if hint := self._environment_variable_hint(loader.get_ids(resources), environment_variable_warning_by_id):
                message += hint
            raise ResourceUpdateError(message) from e

        return len(updated)

    @staticmethod
    def _environment_variable_hint(
        identifiers: list[Hashable],
        environment_variable_warning_by_id: dict[Hashable, EnvironmentVariableMissingWarning],
    ) -> str:
        if not environment_variable_warning_by_id:
            return ""
        missing_variables: set[str] = set()
        for identifier in identifiers:
            if warning := environment_variable_warning_by_id.get(identifier):
                missing_variables.update(warning.variables)
        if missing_variables:
            variables_str = humanize_collection(missing_variables)
            suffix = "s" if len(missing_variables) > 1 else ""
            return f"\n  {HINT_LEAD_TEXT}This is likely due to missing environment variable{suffix}: {variables_str}"
        return ""

    @staticmethod
    def upload_data(
        loader: DataCRUD,
        state: BuildEnvironment,
        dry_run: bool = False,
        verbose: bool = False,
    ) -> UploadDeployResult:
        prefix = "Would upload" if dry_run else "Uploading"
        print(f"[bold]{prefix} {loader.display_name} files to CDF...[/]")

        datapoints = 0
        file_counts = 0
        for message, file_datapoints in loader.upload(state, dry_run):
            if verbose:
                print(message)
            datapoints += file_datapoints
            file_counts += 1

        if datapoints != 0:
            return DatapointDeployResult(
                loader.display_name, points=datapoints, uploaded=file_counts, item_name=loader.item_name
            )
        else:
            return UploadDeployResult(loader.display_name, uploaded=file_counts, item_name=loader.item_name)
