# QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals.
# Lean CLI v1.0. Copyright 2021 QuantConnect Corporation.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json
import re
import uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional, List

from docker.types import Mount
from pkg_resources import Requirement

from lean.components.cloud.module_manager import ModuleManager
from lean.components.config.lean_config_manager import LeanConfigManager
from lean.components.config.output_config_manager import OutputConfigManager
from lean.components.config.project_config_manager import ProjectConfigManager
from lean.components.docker.docker_manager import DockerManager
from lean.components.util.logger import Logger
from lean.components.util.project_manager import ProjectManager
from lean.components.util.temp_manager import TempManager
from lean.components.util.xml_manager import XMLManager
from lean.constants import MODULES_DIRECTORY, TERMINAL_LINK_PRODUCT_ID, LEAN_ROOT_PATH
from lean.constants import DOCKER_PYTHON_SITE_PACKAGES_PATH
from lean.models.docker import DockerImage
from lean.models.utils import DebuggingMethod


class LeanRunner:
    """The LeanRunner class contains the code that runs the LEAN engine locally."""

    def __init__(self,
                 logger: Logger,
                 project_config_manager: ProjectConfigManager,
                 lean_config_manager: LeanConfigManager,
                 output_config_manager: OutputConfigManager,
                 docker_manager: DockerManager,
                 module_manager: ModuleManager,
                 project_manager: ProjectManager,
                 temp_manager: TempManager,
                 xml_manager: XMLManager) -> None:
        """Creates a new LeanRunner instance.

        :param logger: the logger that is used to print messages
        :param project_config_manager: the ProjectConfigManager instance to retrieve project configuration from
        :param lean_config_manager: the LeanConfigManager instance to retrieve Lean configuration from
        :param output_config_manager: the OutputConfigManager instance to retrieve backtest/live configuration from
        :param docker_manager: the DockerManager instance which is used to interact with Docker
        :param module_manager: the ModuleManager instance to retrieve the installed modules from
        :param project_manager: the ProjectManager instance to use for copying source code to output directories
        :param temp_manager: the TempManager instance to use for creating temporary directories
        :param xml_manager: the XMLManager instance to use for reading/writing XML files
        """
        self._logger = logger
        self._project_config_manager = project_config_manager
        self._lean_config_manager = lean_config_manager
        self._output_config_manager = output_config_manager
        self._docker_manager = docker_manager
        self._module_manager = module_manager
        self._project_manager = project_manager
        self._temp_manager = temp_manager
        self._xml_manager = xml_manager

    def run_lean(self,
                 lean_config: Dict[str, Any],
                 environment: str,
                 algorithm_file: Path,
                 output_dir: Path,
                 image: DockerImage,
                 debugging_method: Optional[DebuggingMethod],
                 release: bool,
                 detach: bool) -> None:
        """Runs the LEAN engine locally in Docker.

        Raises an error if something goes wrong.

        :param lean_config: the LEAN configuration to use
        :param environment: the environment to run the algorithm in
        :param algorithm_file: the path to the file containing the algorithm
        :param output_dir: the directory to save output data to
        :param image: the LEAN engine image to use
        :param debugging_method: the debugging method if debugging needs to be enabled, None if not
        :param release: whether C# projects should be compiled in release configuration instead of debug
        :param detach: whether LEAN should run in a detached container
        """
        project_dir = algorithm_file.parent

        # The dict containing all options passed to `docker run`
        # See all available options at https://docker-py.readthedocs.io/en/stable/containers.html
        run_options = self.get_basic_docker_config(lean_config,
                                                   algorithm_file,
                                                   output_dir,
                                                   debugging_method,
                                                   release,
                                                   detach)

        # Set up PTVSD debugging
        if debugging_method == DebuggingMethod.PTVSD:
            run_options["ports"]["5678"] = "5678"

        # Set up VSDBG debugging
        if debugging_method == DebuggingMethod.VSDBG:
            run_options["name"] = "lean_cli_vsdbg"

            # lean_cli_vsdbg is not unique, so we don't store the container name in the output directory's config
            output_config = self._output_config_manager.get_output_config(output_dir)
            output_config.delete("container")

        # Set up Rider debugging
        if debugging_method == DebuggingMethod.Rider:
            run_options["ports"]["22"] = "2222"
            run_options["commands"].append("/usr/sbin/enable_insecure_key")
            run_options["commands"].append("chmod 600 /etc/insecure_key")
            run_options["commands"].append('echo "HostKey /etc/insecure_key" >> /etc/ssh/sshd_config')
            run_options["commands"].append("/usr/sbin/sshd")

            self._docker_manager.create_volume("lean_cli_rider")
            run_options["volumes"]["lean_cli_rider"] = {
                "bind": "/root/.local/share/JetBrains",
                "mode": "rw"
            }

        run_options["commands"].append("exec dotnet QuantConnect.Lean.Launcher.dll")

        # Copy the project's code to the output directory
        self._project_manager.copy_code(algorithm_file.parent, output_dir / "code")

        # Run the engine and log the result
        # Run as a subprocess to capture the output before logging it

        # Format error messages for cleaner output logs
        run_options["format_output"] = self.format_error_before_logging

        success = self._docker_manager.run_image(image, **run_options)

        cli_root_dir = self._lean_config_manager.get_cli_root_directory()
        relative_project_dir = project_dir.relative_to(cli_root_dir)
        relative_output_dir = output_dir.relative_to(cli_root_dir)

        if detach:
            self._temp_manager.delete_temporary_directories_when_done = False

            self._logger.info(
                f"Successfully started '{relative_project_dir}' in the '{environment}' environment in the '{run_options['name']}' container")
            self._logger.info(f"The output will be stored in '{relative_output_dir}'")
            self._logger.info("You can use Docker's own commands to manage the detached container")
        elif success:
            self._logger.info(
                f"Successfully ran '{relative_project_dir}' in the '{environment}' environment and stored the output in '{relative_output_dir}'")
        else:
            raise RuntimeError(
                f"Something went wrong while running '{relative_project_dir}' in the '{environment}' environment, the output is stored in '{relative_output_dir}'")

    def get_basic_docker_config(self,
                                lean_config: Dict[str, Any],
                                algorithm_file: Path,
                                output_dir: Path,
                                debugging_method: Optional[DebuggingMethod],
                                release: bool,
                                detach: bool) -> Dict[str, Any]:
        """Creates a basic Docker config to run the engine with.

        This method constructs the parts of the Docker config that is the same for both the engine and the optimizer.

        :param lean_config: the LEAN configuration to use
        :param algorithm_file: the path to the file containing the algorithm
        :param output_dir: the directory to save output data to
        :param debugging_method: the debugging method if debugging needs to be enabled, None if not
        :param release: whether C# projects should be compiled in release configuration instead of debug
        :param detach: whether LEAN should run in a detached container
        :return: the Docker configuration containing basic configuration to run Lean
        """
        project_dir = algorithm_file.parent
        project_config = self._project_config_manager.get_project_config(project_dir)
        docker_project_config = project_config.get("docker", {})

        # Install the required modules when they're needed
        if lean_config.get("data-provider", None) == "QuantConnect.Lean.Engine.DataFeeds.DownloaderDataProvider" \
            and lean_config.get("data-downloader", None) == "TerminalLinkDataDownloader":
            self._module_manager.install_module(TERMINAL_LINK_PRODUCT_ID, lean_config["job-organization-id"])

        # Force the use of the LocalDisk map/factor providers if no recent zip present and not using ApiDataProvider
        data_dir = self._lean_config_manager.get_data_directory()
        if lean_config.get("data-provider", None) != "QuantConnect.Lean.Engine.DataFeeds.ApiDataProvider":
            self._force_disk_provider_if_necessary(lean_config,
                                                   "map-file-provider",
                                                   "QuantConnect.Data.Auxiliary.LocalZipMapFileProvider",
                                                   "QuantConnect.Data.Auxiliary.LocalDiskMapFileProvider",
                                                   data_dir / "equity" / "usa" / "map_files")
            self._force_disk_provider_if_necessary(lean_config,
                                                   "factor-file-provider",
                                                   "QuantConnect.Data.Auxiliary.LocalZipFactorFileProvider",
                                                   "QuantConnect.Data.Auxiliary.LocalDiskFactorFileProvider",
                                                   data_dir / "equity" / "usa" / "factor_files")

        # Create the output directory if it doesn't exist yet
        if not output_dir.exists():
            output_dir.mkdir(parents=True)

        # Create the storage directory if it doesn't exist yet
        storage_dir = project_dir / "storage"
        if not storage_dir.exists():
            storage_dir.mkdir(parents=True)

        lean_config["debug-mode"] = self._logger.debug_logging_enabled
        lean_config["data-folder"] = "/Lean/Data"
        lean_config["results-destination-folder"] = "/Results"
        lean_config["object-store-root"] = "/Storage"

        # The dict containing all options passed to `docker run`
        # See all available options at https://docker-py.readthedocs.io/en/stable/containers.html
        run_options: Dict[str, Any] = {
            "detach": detach,
            "commands": [],
            "environment": docker_project_config.get("environment", {}),
            "stop_signal": "SIGINT" if debugging_method is None else "SIGKILL",
            "mounts": [],
            "volumes": {},
            "ports": docker_project_config.get("ports", {})
        }

        # Mount the project directory
        run_options["volumes"][str(project_dir)] = {
            "bind": "/LeanCLI",
            "mode": "rw"
        }

        # Check if the user has library projects and mount the Library directory
        library_dir = self._lean_config_manager.get_cli_root_directory() / "Library"
        if library_dir.is_dir():
            # Mount the library projects
            run_options["volumes"][str(library_dir)] = {
                "bind": "/Library",
                "mode": "rw"
            }

        # Mount the data directory
        run_options["volumes"][str(data_dir)] = {
            "bind": "/Lean/Data",
            "mode": "rw"
        }

        # Mount the output directory
        run_options["volumes"][str(output_dir)] = {
            "bind": "/Results",
            "mode": "rw"
        }

        # Mount the local object store directory
        run_options["volumes"][str(storage_dir)] = {
            "bind": "/Storage",
            "mode": "rw"
        }

        # Mount all local files referenced in the Lean config
        cli_root_dir = self._lean_config_manager.get_cli_root_directory()
        for key in ["transaction-log", "terminal-link-symbol-map-file"]:
            if key not in lean_config or lean_config[key] == "":
                continue

            local_path = cli_root_dir / lean_config[key]
            if not local_path.exists():
                local_path.parent.mkdir(parents=True, exist_ok=True)
                local_path.touch()

            run_options["mounts"].append(Mount(target=f"/Files/{key}",
                                               source=str(local_path),
                                               type="bind",
                                               read_only=False))

            lean_config[key] = f"/Files/{key}"

        # Update all hosts that need to point to the host's localhost to host.docker.internal so they resolve properly
        for key in ["terminal-link-server-host"]:
            if key not in lean_config:
                continue

            if lean_config[key] == "localhost" or lean_config[key] == "127.0.0.1":
                lean_config[key] = "host.docker.internal"

        set_up_common_csharp_options_called = False

        # Set up modules
        installed_packages = self._module_manager.get_installed_packages()
        if len(installed_packages) > 0:
            self._logger.debug(f"LeanRunner.run_lean(): installed packages {len(installed_packages)}")
            self.set_up_common_csharp_options(run_options)
            set_up_common_csharp_options_called = True

            # Mount the modules directory
            run_options["volumes"][MODULES_DIRECTORY] = {
                "bind": "/Modules",
                "mode": "ro"
            }

            # Add the modules directory as a NuGet source root
            run_options["commands"].append("dotnet nuget add source /Modules")

            # Create a C# project used to resolve the dependencies of the modules
            run_options["commands"].append("mkdir /ModulesProject")
            run_options["commands"].append("dotnet new sln -o /ModulesProject")
            run_options["commands"].append("dotnet new classlib -o /ModulesProject -f net6.0 --no-restore")
            run_options["commands"].append("rm /ModulesProject/Class1.cs")

            # Add all modules to the project, automatically resolving all dependencies
            for package in installed_packages:
                self._logger.debug(f"LeanRunner.run_lean(): Adding module {package} to the project")
                run_options["commands"].append(f"rm -rf /root/.nuget/packages/{package.name.lower()}")
                run_options["commands"].append(
                    f"dotnet add /ModulesProject package {package.name} --version {package.version}")

            # Copy all module files to /Lean/Launcher/bin/Debug, but don't overwrite anything that already exists
            run_options["commands"].append(
                "python /copy_csharp_dependencies.py /Compile/obj/ModulesProject/project.assets.json")

        # Set up language-specific run options
        self.setup_language_specific_run_options(run_options, project_dir, algorithm_file,
                                            set_up_common_csharp_options_called, release)

        # Save the final Lean config to a temporary file so we can mount it into the container
        config_path = self._temp_manager.create_temporary_directory() / "config.json"
        with config_path.open("w+", encoding="utf-8") as file:
            file.write(json.dumps(lean_config, indent=4))

        # Mount the Lean config
        run_options["mounts"].append(Mount(target=f"{LEAN_ROOT_PATH}/config.json",
                                           source=str(config_path),
                                           type="bind",
                                           read_only=True))

        # Assign the container a name and store it in the output directory's configuration
        run_options["name"] = f"lean_cli_{str(uuid.uuid4()).replace('-', '')}"
        output_config = self._output_config_manager.get_output_config(output_dir)
        output_config.set("container", run_options["name"])
        if "backtest-name" in lean_config:
            output_config.set("backtest-name", lean_config["backtest-name"])

        if "environment" in lean_config and "environments" in lean_config:
            environment = lean_config["environments"][lean_config["environment"]]
            if "live-mode-brokerage" in environment:
                output_config.set("brokerage", environment["live-mode-brokerage"].split(".")[-1])

        return run_options

    def set_up_python_options(self, project_dir: Path, run_options: Dict[str, Any]) -> None:
        """Sets up Docker run options specific to Python projects.

        :param project_dir: the path to the project directory
        :param run_options: the dictionary to append run options to
        """

        # Compile python files
        source_files = self._project_manager.get_source_files(project_dir)
        source_files = [file.relative_to(
            project_dir).as_posix() for file in source_files]
        source_files = [f'"/LeanCLI/{file}"' for file in source_files]

        # Only need to compile files in backtest/live (where the files were mounted in "/LeanCLI") but not research
        run_options["commands"].append(
            f"""if [ -d '/LeanCLI' ];
            then
                python -m compileall {' '.join(source_files)};
            else
                echo '/LeanCLI is not mounted, skipping compilation...';
            fi""")

        # Combine the requirements from all library projects and the current project
        library_dir = self._lean_config_manager.get_cli_root_directory() / "Library"
        requirements_files = list(library_dir.rglob("requirements.txt")) + [project_dir / "requirements.txt"]
        requirements_files = [file for file in requirements_files if file.is_file()]
        requirements = self._concat_python_requirements(requirements_files)

        # Check if we have any dependencies to install, so we don't mount volumes needlessly
        if requirements == "":
            return

        # Create a requirements.txt file for the combined requirements
        requirements_txt = self._temp_manager.create_temporary_directory() / "requirements.txt"
        with requirements_txt.open("w+", encoding="utf-8") as file:
            file.write(requirements)

        # Mount the requirements.txt file
        run_options["mounts"].append(Mount(target="/requirements.txt",
                                           source=str(requirements_txt),
                                           type="bind",
                                           read_only=True))

        # Mount a volume to pip's cache directory so we only download packages once
        self._docker_manager.create_volume("lean_cli_pip")
        run_options["volumes"]["lean_cli_pip"] = {
            "bind": "/root/.cache/pip",
            "mode": "rw"
        }

        # Mount a volume to the user packages directory so we don't install packages every time
        site_packages_volume = self._docker_manager.create_site_packages_volume(requirements_txt)
        run_options["volumes"][site_packages_volume] = {
            "bind": f"{DOCKER_PYTHON_SITE_PACKAGES_PATH}",
            "mode": "rw"
        }

        # Update PATH in the Docker container to prevent pip install warnings about its executables not being on PATH
        run_options["commands"].append('export PATH="$PATH:/root/.local/bin"')

        # Install custom libraries to the cached user packages directory
        # We only need to do this if it hasn't already been done before for this site packages volume
        # To keep track of this we create a special file in the site packages directory after installation
        # If this file already exists we can skip pip install completely
        marker_file = f"{DOCKER_PYTHON_SITE_PACKAGES_PATH}/pip-install-done"
        run_options["commands"].extend([
            f"! test -f {marker_file} && pip install --user --progress-bar off -r /requirements.txt",
            f"touch {marker_file}"
        ])

    def _concat_python_requirements(self, requirements_files: List[Path]) -> str:
        """Combines the requirements from multiple requirements.txt files.

        Ensures there are no duplicate requirements and that all output lines are valid.
        Requirements are sorted alphabetically to ensure consistent output.

        :param requirements_files: the paths to the requirements.txt files
        :return: the normalized requirements from all given requirements.txt files
        """
        requirements = []
        for file in requirements_files:
            for line in file.read_text(encoding="utf-8").splitlines():
                try:
                    requirements.append(Requirement.parse(line))
                except ValueError:
                    pass

        requirements = [str(requirement) for requirement in requirements]
        requirements = sorted(set(requirements))
        return "\n".join(requirements)

    def set_up_csharp_options(self, project_dir: Path, run_options: Dict[str, Any], release: bool) -> None:
        """Sets up Docker run options specific to C# projects.

        :param project_dir: the path to the project directory
        :param run_options: the dictionary to append run options to
        :param release: whether C# projects should be compiled in release configuration instead of debug
        """
        compile_root = self._get_csharp_compile_root(project_dir)

        # Ensure all .csproj files refer to the version of LEAN in the Docker container
        csproj_temp_dir = self._temp_manager.create_temporary_directory()
        for path in compile_root.rglob("*.csproj"):
            self._ensure_csproj_uses_correct_lean(compile_root, path, csproj_temp_dir, run_options)

        # Set up the MSBuild properties
        msbuild_properties = {
            "Configuration": "Release" if release else "Debug",
            "Platform": "AnyCPU",
            "TargetFramework": "net6.0",
            "OutputPath": "/Compile/bin",
            "GenerateAssemblyInfo": "false",
            "GenerateTargetFrameworkAttribute": "false",
            "AppendTargetFrameworkToOutputPath": "false",
            "AutoGenerateBindingRedirects": "true",
            "GenerateBindingRedirectsOutputType": "true",
            "AutomaticallyUseReferenceAssemblyPackages": "false",
            "CopyLocalLockFileAssemblies": "true",
            "PathMap": f"/LeanCLI={str(compile_root)}",
            "NoWarn": ["MSB3277"]
        }

        # Find the .csproj file to compile
        project_file = next(project_dir.glob("*.csproj"))

        # Inherit NoWarn from the user's .csproj
        csproj = self._xml_manager.parse(project_file.read_text(encoding="utf-8"))
        existing_no_warn = csproj.find(".//NoWarn")
        if existing_no_warn is not None:
            codes = [c for c in re.split(r"[^a-zA-Z0-9]", existing_no_warn.text) if c != ""]
            msbuild_properties["NoWarn"] += codes

        # Turn the NoWarn property into a string
        # %3B is the encoded version of ";", because a raw ";" is seen as a separator between properties
        msbuild_properties["NoWarn"] = "%3B".join(msbuild_properties["NoWarn"])

        # Build the project before running LEAN
        relative_project_file = str(project_file.relative_to(compile_root)).replace("\\", "/")
        msbuild_properties = ";".join(f"{key}={value}" for key, value in msbuild_properties.items())
        run_options["commands"].append(f'dotnet build "/LeanCLI/{relative_project_file}" "-p:{msbuild_properties}"')

        # Copy over the algorithm DLL
        # Copy over the project reference DLLs'
        # Copy over all output DLLs that don't already exist in /Lean/Launcher/bin/Debug
        run_options["commands"].append(f"cp -R -n /Compile/bin/. {LEAN_ROOT_PATH}/")

        # Copy over all library DLLs that don't already exist in /Lean/Launcher/bin/Debug
        # CopyLocalLockFileAssemblies does not copy the OS-specific DLLs to the output directory
        # We therefore use a custom Python script that does take the OS into account when deciding what to copy
        run_options["commands"].append(
            f'python /copy_csharp_dependencies.py "/Compile/obj/{project_file.stem}/project.assets.json"')

    def set_up_common_csharp_options(self, run_options: Dict[str, Any]) -> None:
        """Sets up common Docker run options that is needed for all C# work.

        This method is only called if the user has installed modules and/or if the project to run is written in C#.

        :param run_options: the dictionary to append run options to
        """
        # Mount a volume to NuGet's cache directory so we only download packages once
        self._docker_manager.create_volume("lean_cli_nuget")
        run_options["volumes"]["lean_cli_nuget"] = {
            "bind": "/root/.nuget/packages",
            "mode": "rw"
        }

        # Reduce the dotnet output
        run_options["environment"]["DOTNET_NOLOGO"] = "true"
        run_options["environment"]["DOTNET_CLI_TELEMETRY_OPTOUT"] = "true"

        temp_files_directory = self._temp_manager.create_temporary_directory()

        # Create a Directory.Build.props file to ensure /Compile/obj is used instead of <project>/obj
        # This is necessary because the files in obj/ will contain absolute paths, which are valid only in the container
        # Rider is okay with this, but Visual Studio throws a lot of errors if this happens
        directory_build_props = temp_files_directory / "Directory.Build.props"
        with directory_build_props.open("w+", encoding="utf-8") as file:
            file.write("""
<Project>
    <PropertyGroup>
        <BaseIntermediateOutputPath>/Compile/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
        <IntermediateOutputPath>/Compile/obj/$(MSBuildProjectName)/</IntermediateOutputPath>
        <DefaultItemExcludes>$(DefaultItemExcludes);backtests/*/code/**;live/*/code/**;optimizations/*/code/**</DefaultItemExcludes>
        <NoWarn>CS0618</NoWarn>
    </PropertyGroup>
</Project>
            """.strip())

        # Create a Python script that can be used to copy the right C# dependencies to /Lean/Launcher/bin/Debug
        # This script copies the correct DLLs even if a project has OS-specific DLLs
        copy_csharp_dependencies = temp_files_directory / "copy_csharp_dependencies.py"
        with copy_csharp_dependencies.open("w+", encoding="utf-8") as file:
            file.write("""
import json
import os
import platform
import shutil
import sys
from pathlib import Path

project_assets = json.loads(Path(sys.argv[-1]).read_text(encoding="utf-8"))
package_folders = [Path(folder) for folder in project_assets["packageFolders"].keys()]

ubuntu_version = os.popen("lsb_release -rs").read().strip()
accepted_runtime_identifiers = ["base", "unix", "linux", "debian", "ubuntu", f"ubuntu.{ubuntu_version}"]

if platform.machine() in ["arm64", "aarch64"]:
    accepted_runtime_identifiers.extend(["unix-arm", "linux-arm", "debian-arm", "ubuntu-arm", f"ubuntu.{ubuntu_version}-arm"])
else:
    accepted_runtime_identifiers.extend(["unix-x64", "linux-x64", "debian-x64", "ubuntu-x64", f"ubuntu.{ubuntu_version}-x64"])

def copy_file(library_id, partial_path, file_data):
    if "rid" in file_data and file_data["rid"] not in accepted_runtime_identifiers:
        return

    if not file_data.get("copyToOutput", True):
        return

    for folder in package_folders:
        full_path = folder / library_id.lower() / partial_path
        if full_path.exists():
            break
    else:
        return

    output_name = file_data.get("outputPath", full_path.name)

    target_path = Path("/Lean/Launcher/bin/Debug") / output_name
    if not target_path.exists():
        target_path.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy(full_path, target_path)

project_target = list(project_assets["targets"].keys())[0]
for library_id, library_data in project_assets["targets"][project_target].items():
    for key, value in library_data.get("runtimeTargets", {}).items():
        copy_file(library_id, key, value)

    for key, value in library_data.get("runtime", {}).items():
        copy_file(library_id, key, value)

    for key, value in library_data.get("contentFiles", {}).items():
        copy_file(library_id, key, value)
            """.strip())

        run_options["mounts"].extend([Mount(target="/Directory.Build.props",
                                            source=str(directory_build_props),
                                            type="bind",
                                            read_only=False),
                                      Mount(target="/copy_csharp_dependencies.py",
                                            source=str(copy_csharp_dependencies),
                                            type="bind",
                                            read_only=False)])

    def _get_csharp_compile_root(self, project_dir: Path) -> Path:
        """Returns the path to the directory that should be mounted to compile the project directory.

        If the project is part of a solution this is the solution root.
        If the project is not part of a solution this is the project directory itself.

        :param project_dir: the path to the project directory
        :return: the path that should be mounted in the Docker container when compiling the C# project
        """
        current_dir = project_dir.parent
        while current_dir.parent != current_dir:
            if next(current_dir.glob("*.sln"), None) is not None:
                return current_dir

            current_dir = current_dir.parent

        return project_dir

    def _ensure_csproj_uses_correct_lean(self,
                                         compile_root: Path,
                                         csproj_path: Path,
                                         temp_dir: Path,
                                         run_options: Dict[str, Any]) -> None:
        """Ensures a C# project is compiled using the version of LEAN in the Docker container.

        When a .csproj file refers to the NuGet version of LEAN,
        we mount a temporary file on top of it which refers to the version of LEAN in the Docker container.
        In the case there is a breaking change between the two,
        this causes compiling to fail with readable error messages instead of ugly messages at runtime.

        :param compile_root: the path that is mounted in the Docker container
        :param csproj_path: the path to the .csproj file
        :param temp_dir: the temporary directory in which temporary .csproj files should be placed
        :param run_options: the dictionary to append run options to
        """
        csproj = self._xml_manager.parse(csproj_path.read_text(encoding="utf-8"))
        include_added = False

        for package_reference in csproj.iter("PackageReference"):
            if not package_reference.get("Include", "").lower().startswith("quantconnect."):
                continue

            if include_added:
                package_reference.getparent().remove(package_reference)

            package_reference.clear()

            package_reference.tag = "Reference"
            package_reference.set("Include", f"{LEAN_ROOT_PATH}/*.dll")
            package_reference.append(self._xml_manager.parse("<Private>False</Private>"))

            include_added = True

        if not include_added:
            return

        new_csproj_file = temp_dir / csproj_path.relative_to(compile_root)
        new_csproj_file.parent.mkdir(parents=True, exist_ok=True)
        with new_csproj_file.open("w+", encoding="utf-8") as file:
            file.write(self._xml_manager.to_string(csproj))

        run_options["mounts"].append(Mount(target=f"/LeanCLI/{csproj_path.relative_to(compile_root).as_posix()}",
                                           source=str(new_csproj_file),
                                           type="bind",
                                           read_only=True))

    def _force_disk_provider_if_necessary(self,
                                          lean_config: Dict[str, Any],
                                          config_key: str,
                                          zip_provider: str,
                                          disk_provider: str,
                                          zip_dir: Path) -> None:
        """Updates the Lean config to use the disk provider instead of the zip one if there are no zips to use.

        :param lean_config: the Lean config to update
        :param config_key: the key of the configuration property
        :param zip_provider: the fully classified name of the zip provider for this property
        :param disk_provider: the fully classified name of the disk provider for this property
        :param zip_dir: the directory where the zip provider looks for zip files
        """
        if lean_config.get(config_key, None) != zip_provider:
            return

        if not zip_dir.exists():
            lean_config[config_key] = disk_provider
            return

        zip_names = sorted([f.name for f in zip_dir.iterdir() if f.name.endswith(".zip")], reverse=True)
        zip_names = [re.sub(r"[^\d]", "", name) for name in zip_names]

        if len(zip_names) == 0 or (datetime.now() - datetime.strptime(zip_names[0], "%Y%m%d")).days > 7:
            lean_config[config_key] = disk_provider

    def setup_language_specific_run_options(self, run_options, project_dir, algorithm_file,
                                            set_up_common_csharp_options_called, release) -> None:
        # Set up language-specific run options
        if algorithm_file.name.endswith(".py"):
            self.set_up_python_options(project_dir, run_options)
        else:
            if not set_up_common_csharp_options_called:
                self.set_up_common_csharp_options(run_options)
            self.set_up_csharp_options(project_dir, run_options, release)

    def format_error_before_logging(self, chunk: str):
        from lean.components.util import compiler
        errors = []

        # As we don't have algorithm type information. We can check errors for both
        # Python
        jsonString = compiler.get_errors("python", chunk)
        jsonData = json.loads(jsonString)
        errors.extend(jsonData["aErrors"])
        # CSharp
        jsonString = compiler.get_errors("csharp", chunk)
        jsonData = json.loads(jsonString)
        errors.extend(jsonData["aErrors"])

        for error in errors:
            self._logger.info(error)
