from typing import List, Optional, Tuple

import rich_click as click
import yaml

from truefoundry.cli.console import console
from truefoundry.cli.const import GROUP_CLS
from truefoundry.cli.util import handle_exception_wrapper
from truefoundry.deploy.lib.clients.servicefoundry_client import (
    ServiceFoundryServiceClient,
)
from truefoundry.deploy.lib.dao import apply as apply_lib
from truefoundry.deploy.lib.dao import delete as delete_lib
from truefoundry.deploy.lib.model.entity import (
    File,
    ResolvedDirectoryFiles,
    ResolvedResourceInfo,
)


def _validate_options(
    files: Tuple[str, ...],
    directory: Optional[str],
    diffs_only: bool,
    ref: Optional[str],
    show_diff: bool,
    dry_run: bool,
    sync: bool,
) -> None:
    if show_diff and not dry_run:
        raise click.ClickException("--show-diff requires --dry-run")

    if ref is not None and not diffs_only:
        raise click.ClickException("--ref requires --diffs-only")

    if diffs_only and not directory:
        raise click.ClickException("--diffs-only requires -d/--directory")

    if files and directory:
        raise click.ClickException(
            "-f/--file and -d/--directory are mutually exclusive"
        )

    if not files and not directory:
        raise click.ClickException("Either -f/--file or -d/--directory is required")

    if sync and (not directory or not diffs_only):
        raise click.ClickException("--sync requires -d/--directory and --diffs-only")


def _resolve_directory_files(
    directory: str, diffs_only: bool, sync: bool, dry_run: bool, ref: Optional[str]
) -> Optional[ResolvedDirectoryFiles]:
    deleted_files: List[File] = []
    if diffs_only:
        git_changed_yaml_files = apply_lib.get_git_changed_yaml_files(directory, ref)
        resolved_files = git_changed_yaml_files.modified
        deleted_files = git_changed_yaml_files.deleted

        ref_label = ref or "HEAD"
        if not resolved_files and not deleted_files:
            console.print(
                f"[yellow]No changed yaml files found compared to {ref_label}[/]"
            )
            return None
        console.print(
            f"[cyan]Found {len(resolved_files)} changed yaml file(s) and {len(deleted_files)} deleted yaml file(s) compared to {ref_label}[/]"
        )
    else:
        resolved_file_paths = apply_lib.collect_yaml_files(directory)
        resolved_files = [File(path=file_path) for file_path in resolved_file_paths]
        if not resolved_files:
            console.print("[yellow]No yaml files found in the directory[/]")
            return None
        console.print(f"[cyan]Found {len(resolved_files)} yaml file(s) in directory[/]")

    if sync and deleted_files:
        suffix = ""
        if dry_run:
            suffix = "No changes will be made right now."
        else:
            suffix = "Resources from these yaml files will be deleted."
        console.print(f"[cyan]The following files were deleted. {suffix}[/]")
        for file in deleted_files:
            try:
                manifest = yaml.safe_load(file.content)
            except Exception:
                continue
            manifest_type_and_name = apply_lib.get_type_and_name_from_manifest(manifest)
            if manifest_type_and_name is not None:
                console.print(
                    f"  [dim]{file.path}[/] [cyan](type: {manifest_type_and_name.type}, name: {manifest_type_and_name.name})[/]"
                )
            else:
                console.print(
                    f"  [dim]{file.path}[/] [cyan](type: unknown, name: unknown)[/]"
                )
        console.print()

    return ResolvedDirectoryFiles(
        modified=resolved_files,
        deleted=deleted_files,
    )


def _log_dependency_order(sorted_resolved_files: List[str], dry_run: bool) -> None:
    if not dry_run:
        return

    if len(sorted_resolved_files) == 0:
        return

    console.print("[cyan]Applying in dependency order:[/]")
    for f in sorted_resolved_files:
        manifest_type_and_name = apply_lib.read_manifest_type_and_name(f)
        type_str = (
            manifest_type_and_name.type
            if manifest_type_and_name is not None
            else "unknown"
        )
        console.print(f"  [dim]{f}[/] [cyan]({type_str})[/]")
    console.print()


def _log_skipped_resources(
    skipped_resources: List[ResolvedResourceInfo], dry_run: bool
) -> None:
    if not dry_run or not skipped_resources:
        return

    console.print(
        f"[yellow]Skipped dry run for {len(skipped_resources)} resource(s) that have dependencies on other resources:[/]"
    )
    for resource in skipped_resources:
        console.print(
            f"  [dim]Type: {resource.type}, Name: {resource.name if resource.name else '(no name specified)'}[/]",
            highlight=False,
        )


@click.group(
    name="apply",
    cls=GROUP_CLS,
    invoke_without_command=True,
    help="""Create resources by applying manifest locally from TrueFoundry spec.

\b
Examples:
  tfy apply -f manifest.yaml                        Apply a single manifest file
  tfy apply -f team.yaml -f cluster.yaml            Apply multiple files
  tfy apply -d ./manifests                          Apply all yamls in a directory (sorted by dependency order)
  tfy apply -d ./manifests --diffs-only             Apply only git-changed yamls compared to HEAD
  tfy apply -d ./manifests --diffs-only --ref main  Apply only git-changed yamls compared to a branch/tag/commit
  tfy apply -d ./manifests --dry-run                Dry run (simulate without applying)
  tfy apply -d ./manifests --dry-run --show-diff    Dry run with diff output
  tfy apply -d ./manifests --diffs-only --sync      Sync resources by deleting those not present in the manifests
""",
    context_settings={"ignore_unknown_options": True, "allow_interspersed_args": True},
)
@click.option(
    "-f",
    "--file",
    "files",
    type=click.Path(exists=True, dir_okay=False, resolve_path=True),
    help="Path to yaml manifest file (You can apply multiple files at once by providing multiple -f options)",
    show_default=True,
    multiple=True,
)
@click.option(
    "-d",
    "--directory",
    "directory",
    type=click.Path(exists=True, file_okay=False, resolve_path=True),
    help="Path to a directory containing yaml manifest files. All yamls will be collected and applied in dependency order.",
)
@click.option(
    "--diffs-only",
    "--diffs_only",
    is_flag=True,
    show_default=True,
    help="Only apply yaml files that have changed in git (requires -d)",
)
@click.option(
    "--ref",
    type=str,
    default=None,
    help="Git ref (branch, tag, or commit) to compare against when using --diffs-only (default: HEAD)",
)
@click.option(
    "--dry-run",
    "--dry_run",
    is_flag=True,
    show_default=True,
    help="Simulate the process without actually applying the manifest",
)
@click.option(
    "--show-diff",
    "--show_diff",
    is_flag=True,
    show_default=True,
    help="Print manifest differences when using --dry-run",
)
@click.option(
    "--sync",
    is_flag=True,
    show_default=True,
    help="Sync resources by deleting those not present in the manifests (requires -d/--directory and --diffs-only)",
)
@handle_exception_wrapper
def apply_command(
    files: Tuple[str, ...] = (),
    directory: Optional[str] = None,
    diffs_only: bool = False,
    ref: Optional[str] = None,
    dry_run: bool = False,
    show_diff: bool = False,
    sync: bool = False,
):
    _validate_options(files, directory, diffs_only, ref, show_diff, dry_run, sync)

    resolved_file_paths: Optional[List[str]] = None

    if directory:
        resolved_directory_files = _resolve_directory_files(
            directory, diffs_only, sync, dry_run, ref
        )
        if resolved_directory_files is None:
            return
        resolved_file_paths = [file.path for file in resolved_directory_files.modified]
        deleted_files = resolved_directory_files.deleted
    else:
        resolved_file_paths = list(files)

    sf_server_client = ServiceFoundryServiceClient()

    skipped_resources: List[ResolvedResourceInfo] = []
    if dry_run:
        resolved_resources = apply_lib.resolve_resources_for_dry_run(
            resolved_file_paths, sf_server_client
        )
        resolved_file_paths = [
            resource.filepath for resource in resolved_resources.processable_resources
        ]
        skipped_resources = resolved_resources.skipped_resources

    # Sort the resolved files by manifest type according to dependency order
    # TODO: Avoid reading from file again here; save the manifest type and name beforehand with resolved_file_paths
    resolved_file_paths = apply_lib.sort_files_by_manifest_type(resolved_file_paths)
    _log_dependency_order(resolved_file_paths, dry_run)

    error_message = None

    apply_results = apply_lib.apply_manifest_files(
        resolved_file_paths, sf_server_client, dry_run, show_diff
    )

    _log_skipped_resources(skipped_resources, dry_run)

    if not all(apply_result.success for apply_result in apply_results):
        error_message = "Failed to apply one or more resource manifests"

    if sync and deleted_files and not dry_run:
        delete_results = delete_lib.delete_manifest_files(
            files=deleted_files,
            sf_server_client=sf_server_client,
            read_content_from_file=False,
        )

        if not all(delete_result.success for delete_result in delete_results):
            if error_message is None:
                error_message = "Failed to delete one or more resource manifests"
            else:
                error_message = (
                    "Failed to apply and delete one or more resource manifests"
                )

    if error_message:
        raise Exception(error_message)


def get_apply_command():
    return apply_command
