import os
from dataclasses import dataclass, field
from typing import Optional

from tach import filesystem as fs
from tach import errors
from tach.core import PackageTrie, PackageNode, ProjectConfig
from tach.parsing import build_package_trie, get_project_imports


@dataclass
class ErrorInfo:
    source_tags: list[str] = field(default_factory=list)
    invalid_tags: list[str] = field(default_factory=list)
    allowed_tags: list[str] = field(default_factory=list)
    exception_message: str = ""

    @property
    def is_tag_error(self) -> bool:
        return all((self.source_tags, self.invalid_tags))


def is_top_level_package_import(mod_path: str, package: PackageNode) -> bool:
    return mod_path == package.full_path


def import_matches_interface_members(mod_path: str, package: PackageNode) -> bool:
    mod_path_segments = mod_path.rsplit(".", 1)
    if len(mod_path_segments) == 1:
        return mod_path_segments[0] == package.full_path
    else:
        mod_pkg_path, mod_member_name = mod_path_segments
        return (
            mod_pkg_path == package.full_path
            and mod_member_name in package.interface_members
        )


def check_import(
    project_config: ProjectConfig,
    package_trie: PackageTrie,
    import_mod_path: str,
    file_mod_path: str,
    file_nearest_package: Optional[PackageNode] = None,
) -> Optional[ErrorInfo]:
    import_nearest_package = package_trie.find_nearest(import_mod_path)
    if import_nearest_package is None:
        # This shouldn't happen since we intend to filter out any external imports,
        # but we should allow external imports if they have made it here.
        return None

    # Lookup file_mod_path if package not given
    if file_nearest_package is None:
        file_nearest_package = package_trie.find_nearest(file_mod_path)
    # If package not found, we should fail since the implication is that
    # an external package is importing directly from our project
    if file_nearest_package is None:
        return ErrorInfo(
            exception_message=f"Package containing '{file_mod_path}' not found in project.",
        )

    # Imports within the same package are always allowed
    if import_nearest_package == file_nearest_package:
        return None

    import_package_config = import_nearest_package.config
    if import_package_config and import_package_config.strict:
        if not is_top_level_package_import(
            import_mod_path, import_nearest_package
        ) and not import_matches_interface_members(
            import_mod_path, import_nearest_package
        ):
            # In strict mode, import must be of the package itself or one of the
            # interface members (defined in __all__)
            return ErrorInfo(
                exception_message=(
                    f"Package '{import_nearest_package.full_path}' is in strict mode. "
                    "Only imports from the root of this package are allowed. "
                    f"The import '{import_mod_path}' (in '{file_mod_path}') "
                    f"is not included in __all__."
                ),
            )

    # The import must be explicitly allowed based on the tags and top-level config
    if not file_nearest_package.config or not import_nearest_package.config:
        return ErrorInfo(
            exception_message="Could not find package configuration.",
        )
    file_tags = file_nearest_package.config.tags
    import_tags = import_nearest_package.config.tags

    for file_tag in file_tags:
        dependency_tags = project_config.dependencies_for_tag(file_tag)
        if any(
            any(dependency_tag == import_tag for dependency_tag in dependency_tags)
            for import_tag in import_tags
        ):
            # The import has at least one tag which matches at least one expected dependency
            continue
        # This means the import has no tags which the file can depend on
        return ErrorInfo(
            source_tags=file_tags,
            invalid_tags=import_tags,
            allowed_tags=dependency_tags,
        )

    return None


@dataclass
class BoundaryError:
    file_path: str
    line_number: int
    import_mod_path: str
    error_info: ErrorInfo


def check(
    root: str,
    project_config: ProjectConfig,
    exclude_paths: Optional[list[str]] = None,
    exclude_hidden_paths: Optional[bool] = True,
) -> list[BoundaryError]:
    if not os.path.isdir(root):
        raise errors.TachSetupError(f"The path {root} is not a valid directory.")

    cwd = fs.get_cwd()
    try:
        fs.chdir(root)
        # This 'canonicalizes' the path arguments, resolving directory traversal
        root = fs.canonical(root)

        if exclude_paths is not None and project_config.exclude is not None:
            exclude_paths.extend(project_config.exclude)
        else:
            exclude_paths = project_config.exclude

        package_trie = build_package_trie(
            root, exclude_paths=exclude_paths, exclude_hidden_paths=exclude_hidden_paths
        )

        boundary_errors: list[BoundaryError] = []
        for file_path in fs.walk_pyfiles(
            root, exclude_paths=exclude_paths, exclude_hidden_paths=exclude_hidden_paths
        ):
            mod_path = fs.file_to_module_path(file_path)
            nearest_package = package_trie.find_nearest(mod_path)
            if nearest_package is None:
                continue

            # This should only give us imports from within our project
            # (excluding stdlib, builtins, and 3rd party packages)
            project_imports = get_project_imports(
                root,
                file_path,
                ignore_type_checking_imports=project_config.ignore_type_checking_imports,
            )
            for project_import in project_imports:
                check_error = check_import(
                    project_config=project_config,
                    package_trie=package_trie,
                    import_mod_path=project_import.mod_path,
                    file_nearest_package=nearest_package,
                    file_mod_path=mod_path,
                )
                if check_error is None:
                    continue

                boundary_errors.append(
                    BoundaryError(
                        file_path=file_path,
                        import_mod_path=project_import.mod_path,
                        line_number=project_import.line_number,
                        error_info=check_error,
                    )
                )

        return boundary_errors
    finally:
        fs.chdir(cwd)
