from pathlib import Path
from typing import Any, Dict, Iterator, List, Optional, Tuple, Union

import git as gitpython
import yaml

from truefoundry.cli.console import console
from truefoundry.deploy.lib.clients.servicefoundry_client import (
    ServiceFoundryServiceClient,
)
from truefoundry.deploy.lib.diff_utils import print_manifest_diff
from truefoundry.deploy.lib.messages import PROMPT_APPLYING_MANIFEST
from truefoundry.deploy.lib.model.entity import (
    ApplyResult,
    DependencyTreeResponse,
    File,
    GitChangedFiles,
    GitChangedYamlFiles,
    ManifestLike,
    ResolvedResourceInfo,
    ResolvedResources,
)
from truefoundry.pydantic_v1 import ValidationError

MANIFEST_TYPE_ORDER = [
    "team",
    "provider-account",
    "environment",
    "cluster",
    "ml-repo",
    "workspace",
    "service",
    "job",
    "helm",
    "notebook",
    "rstudio",
    "volume",
    "workflow",
    "async-service",
    "application-set",
    "ssh-server",
    "spark-job",
    "secret-group",
    "tracing-project",
    "mcp-server",
    "agent",
    "policy",
    "role",
    "virtual-account",
    "gateway-guardrails-config",
    "gateway-rate-limiting-config",
    "gateway-global-settings",
    "gateway-fallback-config",
    "gateway-load-balancing-config",
    "gateway-budget-config",
    "gateway-data-access-config",
    "gateway-otel-config",
    "gateway-data-routing-config",
    "gateway-installation",
    "settings",
    "alert-config",
]

_UNKNOWN_TYPE_PRIORITY = len(MANIFEST_TYPE_ORDER)


def _get_base_type(type_str: str) -> str:
    """Extract base type, e.g. 'provider-account/gcp' -> 'provider-account'."""
    return type_str.split("/")[0]


def _get_type_priority(type_str: str) -> int:
    base_type = _get_base_type(type_str)
    try:
        return MANIFEST_TYPE_ORDER.index(base_type)
    except ValueError:
        return _UNKNOWN_TYPE_PRIORITY


def get_type_and_name_from_manifest(
    manifest: Dict[str, Any],
) -> Optional[ManifestLike]:
    if manifest and isinstance(manifest.get("type"), str):
        type_str = manifest.get("type")
        # Fallback to type if name is not present in the manifest
        name_str = manifest.get("name") or type_str
        return ManifestLike(type=type_str, name=name_str)
    return None


def read_manifest_type_and_name(filepath: str) -> Optional[ManifestLike]:
    """Read the type and name fields from a manifest file."""
    try:
        manifest = get_manifest_from_file(filepath)
        return get_type_and_name_from_manifest(manifest)
    except Exception:
        pass
    return None


def collect_yaml_files(directory: str) -> List[str]:
    """Collect all .yaml and .yml files from a directory recursively."""
    dir_path = Path(directory).resolve()
    yaml_files: List[str] = []
    for ext in ("*.yaml", "*.yml"):
        yaml_files.extend(str(p) for p in dir_path.rglob(ext))
    return yaml_files


def sort_files_by_manifest_type(files: List[str]) -> List[str]:
    """Sort files by their manifest type according to dependency order."""

    def sort_key(filepath: str) -> Tuple[int, str]:
        manifest_type_and_name = read_manifest_type_and_name(filepath)
        if manifest_type_and_name is None:
            return (_UNKNOWN_TYPE_PRIORITY + 1, filepath)
        return (_get_type_priority(manifest_type_and_name.type), filepath)

    return sorted(files, key=sort_key)


def _open_git_repo(directory: str) -> gitpython.Repo:
    """Open a git repository, raising clear errors for common failure cases."""
    try:
        repo = gitpython.Repo(directory, search_parent_directories=True)
    except gitpython.InvalidGitRepositoryError:
        raise Exception(f"{directory} is not inside a git repository") from None
    except gitpython.NoSuchPathError:
        raise Exception(f"Directory {directory} does not exist") from None
    except gitpython.GitCommandNotFound:
        raise Exception("git is not installed or not found in PATH") from None

    if repo.working_tree_dir is None:
        raise Exception("Bare git repositories are not supported")
    return repo


def _get_changed_files(repo: gitpython.Repo, ref: str) -> GitChangedFiles:
    """Get all modified file paths (tracked + untracked), deleted file paths compared to a ref."""
    modified_files: List[File] = []
    deleted_files: List[File] = []

    try:
        for diff_item in repo.commit(ref).diff(None):
            file_path = diff_item.a_path or diff_item.b_path
            if diff_item.change_type == "D":
                blob_text = ""
                if file_path and diff_item.a_blob:
                    try:
                        blob_text = diff_item.a_blob.data_stream.read().decode(
                            "utf-8", errors="replace"
                        )
                    except Exception:
                        pass
                deleted_files.append(File(path=file_path, content=blob_text))
            else:
                modified_files.append(File(path=file_path))
    except Exception as e:
        raise Exception(f"Failed to compute diff against '{ref}': {e}") from None

    for untracked_file in repo.untracked_files:
        modified_files.append(File(path=untracked_file))
    return GitChangedFiles(modified=modified_files, deleted=deleted_files)


def _get_git_changed_yaml_file_path_if_valid(
    f: str, repo_root: Path, dir_path: Path
) -> Union[Path, None]:
    if not f.endswith((".yaml", ".yml")):
        return None
    abs_path = (repo_root / f).resolve()
    try:
        abs_path.relative_to(dir_path)
    except ValueError:
        return None
    return abs_path


def get_git_changed_yaml_files(
    directory: str, ref: Optional[str] = None
) -> GitChangedYamlFiles:
    """Get YAML files changed in git compared to ref (default: HEAD).

    Includes both tracked changes and untracked (new) files, filtered to
    only those within the given directory.
    """
    dir_path = Path(directory).resolve()
    ref = ref or "HEAD"

    repo = _open_git_repo(str(dir_path))
    repo_root = Path(repo.working_tree_dir)
    changed_files_info = _get_changed_files(repo, ref)

    git_modified_yaml_files: List[File] = []
    for modified_file in changed_files_info.modified:
        abs_path = _get_git_changed_yaml_file_path_if_valid(
            modified_file.path, repo_root, dir_path
        )
        if abs_path and abs_path.is_file():
            git_modified_yaml_files.append(File(path=str(abs_path), content=None))

    git_deleted_yaml_files: List[File] = []
    for deleted_file in changed_files_info.deleted:
        abs_path = _get_git_changed_yaml_file_path_if_valid(
            deleted_file.path, repo_root, dir_path
        )
        if abs_path:
            git_deleted_yaml_files.append(
                File(path=str(abs_path), content=deleted_file.content)
            )

    return GitChangedYamlFiles(
        modified=git_modified_yaml_files,
        deleted=git_deleted_yaml_files,
    )


def _apply_manifest(
    manifest: Dict[str, Any],
    client: Optional[ServiceFoundryServiceClient] = None,
    filename: Optional[str] = None,
    index: Optional[int] = None,
    dry_run: bool = False,
    show_diff: bool = False,
) -> ApplyResult:
    client = client or ServiceFoundryServiceClient()

    file_metadata = ""
    if index is not None:
        file_metadata += f" at index {index}"
    if filename:
        file_metadata += f" from file {filename}"

    try:
        parsed_manifest = ManifestLike.parse_obj(manifest)
    except ValidationError as ex:
        return ApplyResult(
            success=False,
            message=f"Failed to apply manifest{file_metadata}. Error: {ex}",
        )

    prefix = "[Dry Run] " if dry_run else ""
    suffix = " (No changes were applied)" if dry_run else ""

    manifest_name = ""
    if parsed_manifest.name:
        manifest_name = f" {parsed_manifest.name}"

    try:
        api_response = client.apply(
            parsed_manifest.dict(exclude_unset=True, exclude_none=True), dry_run
        )

        # Show diff for dry runs only when show_diff is enabled
        if dry_run and show_diff and api_response.existing_manifest:
            if parsed_manifest.name:
                diff_manifest_name = f"{parsed_manifest.name} ({parsed_manifest.type})"
            else:
                diff_manifest_name = f"manifest ({parsed_manifest.type})"
            print_manifest_diff(
                existing_manifest=api_response.existing_manifest,
                new_manifest=parsed_manifest.dict(
                    exclude_unset=True, exclude_none=True
                ),
                manifest_name=diff_manifest_name,
                console=console,
            )

        return ApplyResult(
            success=True,
            message=(
                f"{prefix}Successfully configured manifest{manifest_name} of type {parsed_manifest.type}.{suffix}"
            ),
        )
    except Exception as ex:
        return ApplyResult(
            success=False,
            message=(
                f"{prefix}Failed to apply manifest{manifest_name} of type {parsed_manifest.type}. Error: {ex}.{suffix}"
            ),
        )


def apply_manifest(
    manifest: Dict[str, Any],
    client: Optional[ServiceFoundryServiceClient] = None,
    dry_run: bool = False,
    show_diff: bool = False,
) -> ApplyResult:
    return _apply_manifest(
        manifest=manifest, client=client, dry_run=dry_run, show_diff=show_diff
    )


def get_manifest_from_file(filepath: str) -> Optional[Dict[str, Any]]:
    try:
        with open(filepath, "r") as f:
            manifest = yaml.safe_load(f)
            if isinstance(manifest, dict):
                return manifest
    except Exception:
        pass
    return None


def apply_manifest_file(
    filepath: str,
    client: Optional[ServiceFoundryServiceClient] = None,
    dry_run: bool = False,
    show_diff: bool = False,
) -> Iterator[ApplyResult]:
    client = client or ServiceFoundryServiceClient()
    filename = Path(filepath).name
    try:
        with open(filepath, "r") as f:
            manifests_it = list(yaml.safe_load_all(f))
    except Exception as ex:
        yield ApplyResult(
            success=False,
            message=f"Failed to read file {filepath} as a valid YAML file. Error: {ex}",
        )
    else:
        prefix = "[Dry Run] " if dry_run else ""
        for index, manifest in enumerate(manifests_it):
            if not isinstance(manifest, dict):
                yield ApplyResult(
                    success=False,
                    message=f"{prefix}Failed to apply manifest at index {index} from file {filename}. Error: A manifest must be a dict, got type {type(manifest)}",
                )
                continue

            yield _apply_manifest(
                manifest=manifest,
                client=client,
                filename=filename,
                index=index,
                dry_run=dry_run,
                show_diff=show_diff,
            )


def apply_manifest_files(
    files: List[str],
    sf_server_client: Optional[ServiceFoundryServiceClient] = None,
    dry_run: bool = False,
    show_diff: bool = False,
) -> List[ApplyResult]:
    apply_results: List[ApplyResult] = []
    for file in files:
        with console.status(PROMPT_APPLYING_MANIFEST.format(file), spinner="dots"):
            for apply_result in apply_manifest_file(
                file, sf_server_client, dry_run, show_diff
            ):
                if apply_result.success:
                    console.print(f"[green]\u2714 {apply_result.message}[/]")
                else:
                    console.print(f"[red]\u2718 {apply_result.message}[/]")

                apply_results.append(apply_result)
    return apply_results


def _get_type_name_key(type: str, name: str) -> str:
    return f"{type}:{name}"


def resolve_resources_for_dry_run(
    given_files: List[str], client: ServiceFoundryServiceClient
) -> ResolvedResources:
    # Map each <type:name> pair to the filepath in given_files list
    type_name_to_filepath: Dict[str, str] = {}
    # Manifests to check for dependencies
    manifests_to_check: List[Dict[str, Any]] = []
    # Type and name pairs list
    type_and_name_list: List[ManifestLike] = []

    for filepath in given_files:
        manifest = get_manifest_from_file(filepath)
        if manifest:
            type_and_name = get_type_and_name_from_manifest(manifest)
            if type_and_name:
                type_and_name_list.append(type_and_name)
                type_name_to_filepath[
                    _get_type_name_key(type_and_name.type, type_and_name.name)
                ] = filepath
                manifests_to_check.append(manifest)

    # Extract the processable and skipped resources from the dependency tree
    processable_resources: List[ResolvedResourceInfo] = []
    skipped_resources: List[ResolvedResourceInfo] = []

    dependency_tree_response: Optional[DependencyTreeResponse] = None
    try:
        dependency_tree_response = client.resolve_dependency_tree(manifests_to_check)
    except Exception:
        dependency_tree_response = None

    if dependency_tree_response is not None:
        for resource in dependency_tree_response.data:
            type_name_key = _get_type_name_key(resource.type, resource.name)
            filepath = type_name_to_filepath[type_name_key]
            resolved_resource_info = ResolvedResourceInfo(
                type=resource.type,
                name=resource.name,
                filepath=filepath,
            )
            if resource.dependencies and len(resource.dependencies) > 0:
                skipped_resources.append(resolved_resource_info)
            else:
                processable_resources.append(resolved_resource_info)
    else:
        for type_and_name in type_and_name_list:
            processable_resources.append(
                ResolvedResourceInfo(
                    type=type_and_name.type,
                    name=type_and_name.name,
                    filepath=type_name_to_filepath[
                        _get_type_name_key(type_and_name.type, type_and_name.name)
                    ],
                )
            )

    return ResolvedResources(
        processable_resources=processable_resources, skipped_resources=skipped_resources
    )
