import shutil
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Type, Union

from ethpm_types import ContractInstance as EthPMContractInstance
from ethpm_types import ContractType, PackageManifest, PackageMeta, Source
from ethpm_types.contract_type import BIP122_URI
from ethpm_types.manifest import PackageName
from ethpm_types.source import Compiler, ContractSource
from ethpm_types.utils import AnyUrl

from ape.api import DependencyAPI, ProjectAPI
from ape.api.networks import LOCAL_NETWORK_NAME
from ape.contracts import ContractContainer, ContractInstance, ContractNamespace
from ape.exceptions import ApeAttributeError, APINotImplementedError, ProjectError
from ape.logging import logger
from ape.managers.base import BaseManager
from ape.managers.project.types import ApeProject, BrownieProject
from ape.utils import get_relative_path


class ProjectManager(BaseManager):
    """
    A manager for accessing contract-types, dependencies, and other project resources.
    Additionally, compile contracts using the
    :meth:`~ape.managers.project.ProjectManager.load_contracts` method.

    Use ``ape.project`` to reference the current project and ``ape.Project`` to reference
    this class uninitialized.

    Raises:
        :class:`~ape.exceptions.ProjectError`: When the project's dependencies are invalid.

    Usage example::

        from ape import project  # "project" is the ProjectManager for the active project
        from ape import Project  # Is a ProjectManager

        # MyContractType (example) is contract type in the active project
        contract_type = project.MyContactType
    """

    path: Path
    """The project path."""

    _cached_projects: Dict[str, ProjectAPI] = {}
    _cached_dependencies: Dict[str, Dict[str, Dict[str, DependencyAPI]]] = {}

    def __init__(
        self,
        path: Path,
    ) -> None:
        self.path = Path(path) if isinstance(path, str) else path

    def __str__(self) -> str:
        return f'Project("{self.path}")'

    @property
    def dependencies(self) -> Dict[str, Dict[str, DependencyAPI]]:
        """
        The package manifests of all dependencies mentioned
        in this project's ``ape-config.yaml`` file.
        """

        return self.load_dependencies()

    @property
    def sources(self) -> Dict[str, Source]:
        """
        A mapping of source identifier to ``ethpm_types.Source`` object.
        """

        return ProjectAPI._create_source_dict(self.source_paths, self.contracts_folder)

    # NOTE: Using these paths should handle the case when the folder doesn't exist
    @property
    def contracts_folder(self) -> Path:
        """
        The path to project's ``contracts/`` directory.

        Returns:
            pathlib.Path
        """

        folder = self.config_manager.contracts_folder
        if folder is None:
            # This happens when using normal Python REPL
            # and perhaps other times when loading a project before config.
            self.config_manager.load()
            folder = self.config_manager.contracts_folder
            if folder is None:
                # Was set explicitly to `None` in config.
                return self.path / "contracts"

        return folder

    @property
    def source_paths(self) -> List[Path]:
        """
        All the source files in the project.
        Excludes files with extensions that don't have a registered compiler.

        Returns:
            List[pathlib.Path]: A list of a source file paths in the project.
        """
        files: List[Path] = []
        if not self.contracts_folder.is_dir():
            return files

        for extension in self.compiler_manager.registered_compilers:
            files.extend((x for x in self.contracts_folder.rglob(f"*{extension}") if x.is_file()))

        return files

    @property
    def sources_missing(self) -> bool:
        """
        ``True`` when there are no contracts anywhere to be found
        in the project. ``False`` otherwise.
        """

        return not self.contracts_folder.is_dir() or not self.contracts_folder.iterdir()

    @property
    def interfaces_folder(self) -> Path:
        """
        The path to the ``interfaces/`` directory of the project.

        Returns:
            pathlib.Path
        """

        return self.path / "interfaces"

    @property
    def scripts_folder(self) -> Path:
        """
        The path to the ``scripts/`` directory of the project.

        Returns:
            pathlib.Path
        """

        return self.path / "scripts"

    @property
    def tests_folder(self) -> Path:
        """
        The path to the ``tests/`` directory of the project.

        Returns:
            pathlib.Path
        """

        return self.path / "tests"

    @property
    def compiler_data(self) -> List[Compiler]:
        """
        A list of ``Compiler`` objects representing the raw-data specifics of a compiler.
        """
        return self._get_compiler_data()

    def _get_compiler_data(self, compile_if_needed: bool = True):
        contract_types: Iterable[ContractType] = (
            self.contracts.values()
            if compile_if_needed
            else self._get_cached_contract_types().values()
        )
        compiler_list: List[Compiler] = []
        contracts_folder = self.config_manager.contracts_folder
        for ext, compiler in self.compiler_manager.registered_compilers.items():
            sources = [x for x in self.source_paths if x.is_file() and x.suffix == ext]
            if not sources:
                continue

            try:
                version_map = compiler.get_version_map(sources, contracts_folder)
            except APINotImplementedError:
                versions = list(compiler.get_versions(sources))
                if len(versions) == 0:
                    # Skipping compilers that don't use versions
                    # These are unlikely to be part of the published manifest
                    continue
                elif len(versions) > 1:
                    raise (ProjectError(f"Unable to create version map for '{ext}'."))

                version = versions[0]
                version_map = {version: sources}

            settings = compiler.get_compiler_settings(sources, base_path=contracts_folder)
            for version, paths in version_map.items():
                version_settings = settings.get(version, {}) if version and settings else {}
                source_ids = [str(get_relative_path(p, contracts_folder)) for p in paths]
                filtered_contract_types = [
                    ct for ct in contract_types if ct.source_id in source_ids
                ]
                contract_type_names = [ct.name for ct in filtered_contract_types]
                compiler_list.append(
                    Compiler(
                        name=compiler.name,
                        version=str(version),
                        settings=version_settings,
                        contractTypes=contract_type_names,
                    )
                )
        return compiler_list

    @property
    def meta(self) -> PackageMeta:
        """
        Metadata about the active project as per EIP
        https://eips.ethereum.org/EIPS/eip-2678#the-package-meta-object
        Use when publishing your package manifest.
        """

        return self.config_manager.meta

    @property
    def tracked_deployments(self) -> Dict[BIP122_URI, Dict[str, EthPMContractInstance]]:
        """
        Deployments that have been explicitly tracked via
        :meth:`~ape.managers.project.manager.ProjectManager.track_deployment`.
        These deployments will be included in the final package manifest upon publication
        of this package.
        """

        deployments: Dict[BIP122_URI, Dict[str, EthPMContractInstance]] = {}
        if not self._package_deployments_folder.is_dir():
            return deployments

        for ecosystem_path in [x for x in self._package_deployments_folder.iterdir() if x.is_dir()]:
            for deployment_path in [x for x in ecosystem_path.iterdir() if x.suffix == ".json"]:
                ethpm_instance = EthPMContractInstance.parse_file(deployment_path)
                if not ethpm_instance:
                    continue

                uri = BIP122_URI(f"blockchain://{ecosystem_path.name}/block/{ethpm_instance.block}")
                deployments[uri] = {deployment_path.stem: ethpm_instance}

        return deployments

    @property
    def project_types(self) -> List[Type[ProjectAPI]]:
        """
        The available :class:`~ape.api.project.ProjectAPI` types available,
        such as :class:`~ape.managers.project.ApeProject`, which is the default.
        """

        project_classes = []
        for _, (project_class,) in self.plugin_manager.projects:
            project_classes.append(project_class)

        project_classes.append(BrownieProject)
        project_classes.append(ApeProject)

        return project_classes

    @property
    def local_project(self) -> ProjectAPI:
        return self.get_project(self.path, contracts_folder=self.contracts_folder)

    def extract_manifest(self) -> PackageManifest:
        """
        Extracts a package manifest from the project.

        Returns:
            ethpm_types.manifest.PackageManifest
        """
        manifest = self.local_project.create_manifest()
        manifest.meta = self.meta
        manifest.compilers = self.compiler_data
        manifest.deployments = self.tracked_deployments
        manifest.dependencies = self._extract_manifest_dependencies()
        return manifest

    def _extract_manifest_dependencies(self) -> Optional[Dict[PackageName, AnyUrl]]:
        package_dependencies: Dict[PackageName, AnyUrl] = {}
        for dependency_config in self.config_manager.dependencies:
            package_name = dependency_config.name.replace("_", "-").lower()
            package_dependencies[PackageName(package_name)] = dependency_config.uri

        return package_dependencies

    @property
    def _package_deployments_folder(self) -> Path:
        return self.local_project._cache_folder / "deployments"

    @property
    def _contract_sources(self) -> List[ContractSource]:
        sources = []
        for contract in self.contracts.values():
            contract_src = self._create_contract_source(contract)
            if contract_src:
                sources.append(contract_src)

        return sources

    def get_project(
        self,
        path: Path,
        contracts_folder: Optional[Path] = None,
        name: Optional[str] = None,
        version: Optional[str] = None,
    ) -> ProjectAPI:
        """
        Get the project at the given path.
        Returns the first :class:`~ape.api.projects.ProjectAPI` it finds where it
        is valid.

        Args:
            path (pathlib.Path): The path to the project.
            contracts_folder (pathlib.Path): The path to the contracts folder. Defaults
              to ``<path>/contracts``.
            name (str): The name of the project. Only necessary when this project is
              a dependency. Defaults to ``None``.
            version (str): The project's version. Only necessary when this project is
              a dependency. Defaults to ``None``.

        Returns:
            :class:`~ape.api.projects.ProjectAPI`
        """

        if path.name in self._cached_projects:
            cached_project = self._cached_projects[path.name]
            if (
                version == cached_project.version
                and contracts_folder is not None
                and contracts_folder == cached_project.contracts_folder
            ):
                return cached_project

        contracts_folder = (
            (path / contracts_folder).expanduser().resolve()
            if contracts_folder
            else path / "contracts"
        )
        if not contracts_folder.is_dir():
            extensions = list(self.compiler_manager.registered_compilers.keys())
            path_patterns_to_ignore = self.config_manager.compiler.ignore_files

            def find_contracts_folder(sub_dir: Path) -> Optional[Path]:
                # Check if config file exists
                files_to_ignore = []
                for pattern in path_patterns_to_ignore:
                    files_to_ignore.extend(list(sub_dir.glob(pattern)))

                next_subs = []
                for sub in sub_dir.iterdir():
                    if sub.name.startswith("."):
                        continue

                    if sub.is_file() and sub not in files_to_ignore:
                        if sub.suffix in extensions:
                            return sub.parent

                    elif sub.is_dir():
                        next_subs.append(sub)

                # No source was found. Search next level of dirs.
                for next_sub in next_subs:
                    found = find_contracts_folder(next_sub)
                    if found:
                        return found

                return None

            contracts_folder = find_contracts_folder(path) or contracts_folder

        def _try_create_project(proj_cls: Type[ProjectAPI]) -> Optional[ProjectAPI]:
            with self.config_manager.using_project(
                path, contracts_folder=contracts_folder
            ) as _project:
                proj = proj_cls(
                    contracts_folder=_project.contracts_folder,
                    name=name,
                    path=path,
                    version=version,
                )
                if proj.is_valid:
                    return proj

            return None

        project_plugin_types = [pt for pt in self.project_types if not issubclass(pt, ApeProject)]
        for project_cls in project_plugin_types:
            project = _try_create_project(project_cls)
            if project:
                self._cached_projects[path.name] = project
                return project

        # Try 'ApeProject' last, in case there was a more specific one earlier.
        ape_project = _try_create_project(ApeProject)
        if ape_project:
            self._cached_projects[path.name] = ape_project
            return ape_project

        raise ProjectError(f"'{self.path.name}' is not recognized as a project.")

    @property
    def contracts(self) -> Dict[str, ContractType]:
        """
        A dictionary of contract names to their type.
        See :meth:`~ape.managers.project.ProjectManager.load_contracts` for more information.

        Returns:
            Dict[str, ``ContractType``]
        """
        if self.local_project._cached_manifest is None:
            return self.load_contracts()

        return self.local_project.contracts

    def __getattr__(self, attr_name: str) -> Union[ContractContainer, ContractNamespace]:
        """
        Get a contract container from an existing contract type in
        the local project using ``.`` access.

        **NOTE**: To get a dependency contract, use
        :py:attr:`~ape.managers.project.ProjectManager.dependencies`.

        Usage example::

            from ape import project

            contract = project.MyContract

        Raises:
            :class:`~ape.exceptions.ApeAttributeError`: When the given name is not
              a contract in the project.

        Args:
            attr_name (str): The name of the contract in the project.

        Returns:
            :class:`~ape.contracts.ContractContainer`
        """

        result = self._get_attr(attr_name)
        if result:
            return result

        # Contract not found. Seek and re-compile missing contract types from sources.
        # This assists when build artifacts accidentally get deleted.
        all_source_ids = list(self.project_manager.sources.keys())
        compiled_source_ids = [x.source_id for x in self.contracts.values()]
        missing_sources = [
            self.contracts_folder / x for x in all_source_ids if x not in compiled_source_ids
        ]
        contract_types = self.compiler_manager.compile(missing_sources)

        # Cache all contract types that were missing for next time.
        for ct in contract_types.values():
            if not ct.name:
                continue

            # We know if we get here that the path does not exist.
            path = self.local_project._cache_folder / f"{ct.name}.json"
            path.write_text(ct.json())
            if self.local_project._contracts is None:
                self.local_project._contracts = {ct.name: ct}
            else:
                self.local_project._contracts[ct.name] = ct

        contract_type = contract_types.get(attr_name)
        if not contract_type:
            # Still not found. Contract likely doesn't exist.
            return self._handle_attr_or_contract_not_found(attr_name)

        result = self._get_attr(attr_name)
        if not result:
            # Shouldn't happen.
            return self._handle_attr_or_contract_not_found(attr_name)

        return result

    def _get_attr(self, attr_name: str):
        # Fixes anomaly when accessing non-ContractType attributes.
        # Returns normal attribute if exists. Raises 'AttributeError' otherwise.
        try:
            return self.__getattribute__(attr_name)
        except AttributeError:
            # Check if a contract.
            pass

        try:
            # NOTE: Will compile project (if needed)
            if contract := self._get_contract(attr_name):
                return contract

            # Check if using namespacing.
            namespaced_contracts = [
                ct
                for ct in [
                    self._get_contract(ct.name)
                    for n, ct in self.contracts.items()
                    if ct.name and "." in n and n.split(".")[0] == attr_name
                ]
                if ct
            ]
            if namespaced_contracts:
                return ContractNamespace(attr_name, namespaced_contracts)

        except Exception as err:
            # __getattr__ has to raise `AttributeError`
            raise ApeAttributeError(str(err)) from err

        return None

    def _handle_attr_or_contract_not_found(self, attr_name: str):
        message = f"{self.__class__.__name__} has no attribute or contract named '{attr_name}'."

        file_check_appended = False
        for file in self.contracts_folder.glob("**/*"):
            # Possibly, the user was trying to use a source ID instead of a contract name.
            if file.stem != attr_name:
                continue

            message = (
                f"{message} However, there is a source file named '{attr_name}', "
                "did you mean to reference a contract name from this source file?"
            )
            file_check_appended = True
            break

        # Possibly, the user does not have compiler plugins installed or working.
        missing_exts = self.extensions_with_missing_compilers([])
        if missing_exts:
            start = "Else, could" if file_check_appended else "Could"
            message = (
                f"{message} {start} it be from one of the missing compilers for extensions: "
                + f'{", ".join(sorted(missing_exts))}?'
            )

        raise ApeAttributeError(message)

    def get_contract(self, contract_name: str) -> ContractContainer:
        """
        Get a contract container from an existing contract type in
        the local project by name.

        **NOTE**: To get a dependency contract, use
        :py:attr:`~ape.managers.project.ProjectManager.dependencies`.

        Raises:
            KeyError: When the given name is not a contract in the project.

        Args:
            contract_name (str): The name of the contract in the project.

        Returns:
            :class:`~ape.contracts.ContractContainer`
        """

        contract = self._get_contract(contract_name)
        if not contract:
            raise ValueError(f"No contract found with name '{contract_name}'.")

        return contract

    def extensions_with_missing_compilers(
        self, extensions: Optional[List[str]] = None
    ) -> List[str]:
        """
        All file extensions in the ``contracts/`` directory (recursively)
        that do not correspond to a registered compiler.

        Args:
            extensions (List[str], optional): If provided, returns only extensions that
                are in this list. Useful for checking against a subset of source files.

        Returns:
            List[str]: A list of file extensions found in the ``contracts/`` directory
            that do not have associated compilers installed.
        """
        extensions_found = []

        def _append_extensions_in_dir(directory: Path):
            if not directory.is_dir():
                return

            for file in directory.iterdir():
                if file.is_dir():
                    _append_extensions_in_dir(file)
                elif (
                    file.suffix
                    and file.suffix not in extensions_found
                    and file.suffix not in self.compiler_manager.registered_compilers
                ):
                    extensions_found.append(file.suffix)

        _append_extensions_in_dir(self.contracts_folder)
        if extensions:
            extensions_found = [e for e in extensions_found if e in extensions]

        return extensions_found

    def lookup_path(self, key_contract_path: Union[Path, str]) -> Optional[Path]:
        """
        Figure out the full path of the contract from the given ``key_contract_path``.

        For example, give it ``HelloWorld`` and it returns
        ``<absolute-project-path>/<contracts-folder>/HelloWorld.sol``.

        Another example is to give it ``contracts/HelloWorld.sol`` and it also
        returns ``<absolute-project-path>/<contracts-folder>/HelloWorld.sol``.

        Args:
            key_contract_path (pathlib.Path, str): A sub-path to a contract or a source ID.

        Returns:
            pathlib.Path: The path if it exists, else ``None``.
        """

        path = Path(key_contract_path)
        ext = path.suffix or None

        def find_in_dir(dir_path: Path) -> Optional[Path]:
            if not dir_path.is_dir():
                return None

            for file_path in dir_path.iterdir():
                if file_path.is_dir():
                    result = find_in_dir(file_path)
                    if result:
                        return result

                # If the user provided an extension, it has to match.
                ext_okay = ext == file_path.suffix if ext is not None else True

                # File found
                if file_path.stem == path.stem and ext_okay:
                    return file_path

            return None

        return find_in_dir(self.contracts_folder)

    def load_contracts(
        self, file_paths: Optional[Union[Iterable[Path], Path]] = None, use_cache: bool = True
    ) -> Dict[str, ContractType]:
        """
        Compile and get the contract types in the project.
        This is called when invoking the CLI command ``ape compile`` as well as prior to running
        scripts or tests in ``ape``, such as from ``ape run`` or ``ape test``.

        Args:
            file_paths (Optional[Union[List[Path], Path]]):
              Provide one or more contract file-paths to load. If excluded,
              will load all the contracts.
            use_cache (Optional[bool]): Set to ``False`` to force a re-compile.
              Defaults to ``True``.

        Returns:
            Dict[str, ``ContractType``]: A dictionary of contract names to their
            types for each compiled contract.
        """

        in_source_cache = self.contracts_folder / ".cache"
        if not use_cache and in_source_cache.is_dir():
            shutil.rmtree(str(in_source_cache))

        if isinstance(file_paths, Path):
            file_path_list = [file_paths]
        elif file_paths is not None:
            file_path_list = list(file_paths)
        else:
            file_path_list = None

        manifest = self.local_project.create_manifest(
            file_paths=file_path_list, use_cache=use_cache
        )
        return manifest.contract_types or {}

    def _get_cached_contract_types(self) -> Dict[str, ContractType]:
        if not self.local_project.cached_manifest:
            return {}

        return self.local_project.cached_manifest.contract_types or {}

    def load_dependencies(self, use_cache: bool = True) -> Dict[str, Dict[str, DependencyAPI]]:
        project_id = str(self.path)
        if use_cache and project_id in self._cached_dependencies:
            return self._cached_dependencies[project_id]

        for dependency_config in self.config_manager.dependencies:
            dependency_name = dependency_config.name
            version_id = dependency_config.version_id
            project_dependencies = self._cached_dependencies.get(project_id, {})

            if (
                use_cache
                and dependency_name in project_dependencies
                and version_id in project_dependencies[dependency_name]
            ):
                # Already cached
                continue

            # Cache manifest for next time.
            if dependency_name in project_dependencies:
                # Dependency is cached but version is not.
                project_dependencies[dependency_name][version_id] = dependency_config
            else:
                # First time caching dependency
                project_dependencies[dependency_name] = {version_id: dependency_config}

            self._cached_dependencies[project_id] = project_dependencies

            # Only extract manifest if wasn't cached and must happen after caching.
            dependency_config.extract_manifest(use_cache=use_cache)

        return self._cached_dependencies.get(project_id, {})

    def track_deployment(self, contract: ContractInstance):
        """
        Indicate that a contract deployment should be included in the package manifest
        upon publication.

        **NOTE**: Deployments are automatically tracked for contracts. However, only
        deployments passed to this method are included in the final, publishable manifest.

        Args:
            contract (:class:`~ape.contracts.base.ContractInstance`): The contract
              to track as a deployment of the project.
        """

        network = self.provider.network.name
        if network == LOCAL_NETWORK_NAME or network.endswith("-fork"):
            raise ProjectError("Can only publish deployments on a live network.")

        contract_name = contract.contract_type.name
        receipt = contract.receipt
        if not receipt:
            raise ProjectError(f"Contract '{contract_name}' transaction receipt is unknown.")

        block_number = receipt.block_number
        block_hash_bytes = self.provider.get_block(block_number).hash
        if not block_hash_bytes:
            # Mostly for mypy, not sure this can ever happen.
            raise ProjectError(
                f"Block hash containing transaction for '{contract_name}' "
                f"at block_number={block_number} is unknown."
            )

        block_hash = block_hash_bytes.hex()
        artifact = EthPMContractInstance(
            address=contract.address,
            block=block_hash,
            contractType=contract_name,
            transaction=contract.txn_hash,
            runtimeBytecode=contract.contract_type.runtime_bytecode,
        )

        block_0_hash = self.provider.get_block(0).hash
        if not block_0_hash:
            raise ProjectError("Chain missing hash for block 0 (required for BIP-122 chain ID).")

        bip122_chain_id = block_0_hash.hex()
        deployments_folder = self._package_deployments_folder / bip122_chain_id
        deployments_folder.mkdir(exist_ok=True, parents=True)
        destination = deployments_folder / f"{contract_name}.json"

        if destination.is_file():
            logger.debug("Deployment already tracked. Re-tracking.")
            destination.unlink()

        destination.write_text(artifact.json())

    def _create_contract_source(self, contract_type: ContractType) -> Optional[ContractSource]:
        if not contract_type.source_id:
            return None

        src = self._lookup_source(contract_type.source_id)
        if not src:
            return None

        try:
            return ContractSource.create(contract_type, src, self.contracts_folder)
        except (ValueError, FileNotFoundError):
            return None

    def _lookup_source(self, source_id: str) -> Optional[Source]:
        source_path = self.lookup_path(source_id)
        if source_path and source_path.is_file():
            result = self.local_project._create_source_dict(source_path, self.contracts_folder)
            return next(iter(result.values())) if result else None

        return None

    def _get_contract(self, name: str) -> Optional[ContractContainer]:
        if name in self.contracts:
            return self.chain_manager.contracts.get_container(self.contracts[name])

        return None

    # def publish_manifest(self):
    #     manifest = self.manifest.dict()
    #     if not manifest["name"]:
    #         raise ProjectError("Need name to release manifest")
    #     if not manifest["version"]:
    #         raise ProjectError("Need version to release manifest")

    #     TODO: Publish sources to IPFS and replace with CIDs
    #     TODO: Publish to IPFS
