import os
import shlex
import shutil
import httpx

import subprocess
import sys
import tempfile
from logging import Logger
from typing import Any, Dict, List, Optional, Tuple

from xbom_lib import BOMResult, XBOMGenerator

CDXGEN_IMAGE_VERSION = os.getenv("CDXGEN_IMAGE_VERSION", "latest")
DEFAULT_IMAGE_NAME = "default-secure" if os.getenv("CDXGEN_SECURE_MODE", "") in ("true", "1") else "default"

PROJECT_TYPE_IMAGE = {"default": f"ghcr.io/cyclonedx/cdxgen:{CDXGEN_IMAGE_VERSION}",
                      "deno": f"ghcr.io/cyclonedx/cdxgen-deno:{CDXGEN_IMAGE_VERSION}",
                      "bun": f"ghcr.io/cyclonedx/cdxgen-bun:{CDXGEN_IMAGE_VERSION}",
                      "default-secure": f"ghcr.io/cyclonedx/cdxgen-secure:{CDXGEN_IMAGE_VERSION}",
                      "java": f"ghcr.io/cyclonedx/cdxgen-java:{CDXGEN_IMAGE_VERSION}",
                      "android": f"ghcr.io/cyclonedx/cdxgen-java:{CDXGEN_IMAGE_VERSION}",
                      "java-slim": f"ghcr.io/cyclonedx/cdxgen-java-slim:{CDXGEN_IMAGE_VERSION}",
                      "java11-slim": f"ghcr.io/cyclonedx/cdxgen-java11-slim:{CDXGEN_IMAGE_VERSION}",
                      "java11": f"ghcr.io/cyclonedx/cdxgen-java11:{CDXGEN_IMAGE_VERSION}",
                      "java17": f"ghcr.io/cyclonedx/cdxgen-java17:{CDXGEN_IMAGE_VERSION}",
                      "node20": f"ghcr.io/cyclonedx/cdxgen-node20:{CDXGEN_IMAGE_VERSION}",
                      "python39": f"ghcr.io/cyclonedx/cdxgen-python39:{CDXGEN_IMAGE_VERSION}",
                      "python310": f"ghcr.io/cyclonedx/cdxgen-python310:{CDXGEN_IMAGE_VERSION}",
                      "python311": f"ghcr.io/cyclonedx/cdxgen-python311:{CDXGEN_IMAGE_VERSION}",
                      "python312": f"ghcr.io/cyclonedx/cdxgen-python312:{CDXGEN_IMAGE_VERSION}",
                      "python": f"ghcr.io/cyclonedx/cdxgen-python312:{CDXGEN_IMAGE_VERSION}",
                      "swift": f"ghcr.io/cyclonedx/cdxgen-debian-swift6:{CDXGEN_IMAGE_VERSION}",
                      "swift6": f"ghcr.io/cyclonedx/cdxgen-debian-swift6:{CDXGEN_IMAGE_VERSION}",
                      "ruby26": f"ghcr.io/cyclonedx/cdxgen-debian-ruby26:{CDXGEN_IMAGE_VERSION}",
                      "ruby33": f"ghcr.io/cyclonedx/cdxgen-debian-ruby33:{CDXGEN_IMAGE_VERSION}",
                      "ruby34": f"ghcr.io/cyclonedx/cdxgen-debian-ruby34:{CDXGEN_IMAGE_VERSION}",
                      "ruby": f"ghcr.io/cyclonedx/cdxgen-debian-ruby34:{CDXGEN_IMAGE_VERSION}",
                      "dotnet-core": f"ghcr.io/cyclonedx/cdxgen-debian-dotnet6:{CDXGEN_IMAGE_VERSION}",
                      "dotnet-framework": f"ghcr.io/cyclonedx/cdxgen-debian-dotnet6:{CDXGEN_IMAGE_VERSION}",
                      "dotnet6": f"ghcr.io/cyclonedx/cdxgen-debian-dotnet6:{CDXGEN_IMAGE_VERSION}",
                      "dotnet7": f"ghcr.io/cyclonedx/cdxgen-dotnet7:{CDXGEN_IMAGE_VERSION}",
                      "dotnet8": f"ghcr.io/cyclonedx/cdxgen-debian-dotnet8:{CDXGEN_IMAGE_VERSION}",
                      "dotnet9": f"ghcr.io/cyclonedx/cdxgen-debian-dotnet9:{CDXGEN_IMAGE_VERSION}",
                      "dotnet": f"ghcr.io/cyclonedx/cdxgen-debian-dotnet9:{CDXGEN_IMAGE_VERSION}", }


def get_env_options_value(options: Dict, k: str, default: str = None) -> str:
    return os.getenv(k.upper(), options.get(k.lower(), default))


def get_image_for_type(options: Dict, project_type: str | list | None) -> str:
    # For multiple project types, prefer the default all-in-one image
    if not project_type or (isinstance(project_type, list) and len(project_type) > 1):
        return PROJECT_TYPE_IMAGE.get(DEFAULT_IMAGE_NAME)
    ptype = project_type[0]
    return get_env_options_value(options, f"cdxgen_image_{ptype}",
                                 PROJECT_TYPE_IMAGE.get(ptype) or PROJECT_TYPE_IMAGE.get(DEFAULT_IMAGE_NAME))


def resource_path(relative_path):
    """
    Determine the absolute path of a resource file based on its relative path.

    :param relative_path: Relative path of the resource file.
    :return: Absolute path of the resource file
    """
    try:
        base_path = sys._MEIPASS
    except Exception:
        base_path = os.path.dirname(__file__)
    return os.path.join(base_path, relative_path)


def exec_tool(args: List[str], cwd: str = None, env: Dict = None, stdout: int = subprocess.PIPE,
              logger: Logger = None) -> BOMResult:
    """
    Convenience method to invoke cli tools

    :param args: Command line arguments
    :param cwd: Working directory
    :param env: Environment variables
    :param stdout: Specifies stdout of command
    :param logger: Logger object
    """
    if env is None:
        env = os.environ.copy()
    result = BOMResult(success=True)
    try:
        cp = subprocess.run(args, stdout=stdout, stderr=subprocess.STDOUT, cwd=cwd, env=env,
                            shell=sys.platform == "win32", encoding="utf-8", check=False, )
        result.command_output = cp.stdout
        if logger and stdout != subprocess.DEVNULL:
            logger.debug(cp.stdout)
    except Exception as e:
        result.success = False
        result.command_output = f"Exception while running cdxgen: {e}"
    return result


def find_cdxgen_cmd(use_bin=True, logger: Logger = None):
    if use_bin:
        cdxgen_cmd = os.environ.get("CDXGEN_CMD", "cdxgen")
        if not shutil.which(cdxgen_cmd):
            local_bin = resource_path(
                os.path.join("local_bin", "cdxgen.exe" if sys.platform == "win32" else "cdxgen", ))
            if not os.path.exists(local_bin):
                if logger:
                    logger.info("%s command not found. Please install using npm install "
                                "@cyclonedx/cdxgen or set PATH variable", cdxgen_cmd, )
                return False
            cdxgen_cmd = local_bin
            # Set the plugins directory as an environment variable
            os.environ["CDXGEN_PLUGINS_DIR"] = resource_path("local_bin")
            return cdxgen_cmd
        else:
            return cdxgen_cmd
    else:
        lbin = os.getenv("APPDATA") if sys.platform == "win32" else "local_bin"
        local_bin = resource_path(os.path.join(f"{lbin}\\npm\\" if sys.platform == "win32" else "local_bin",
                                               "cdxgen" if sys.platform != "win32" else "cdxgen.cmd", ))
        if not os.path.exists(local_bin):
            if logger:
                logger.info("%s command not found. Please install using npm install "
                            "@cyclonedx/cdxgen or set PATH variable", local_bin, )
            return None
        cdxgen_cmd = local_bin
        # Set the plugins directory as an environment variable
        os.environ["CDXGEN_PLUGINS_DIR"] = (resource_path("local_bin") if sys.platform != "win32" else resource_path(
            os.path.join(lbin,
                         "\\npm\\node_modules\\@cyclonedx\\cdxgen\\node_modules\\@cyclonedx\\cdxgen-plugins-bin\\plugins", )))
        return cdxgen_cmd


class CdxgenGenerator(XBOMGenerator):
    """
    Concrete implementation of XBOMGenerator using cdxgen.
    """

    def generate(self) -> BOMResult:
        """
        Generate the BOM using the cdxgen tool.
        """
        options = self.options
        project_type = self.options.get("project_type", [])
        techniques = self.options.get("techniques") or []
        lifecycles = self.options.get("lifecycles") or []
        # Implement the BOM generation logic using cdxgen.
        cdxgen_cmd = find_cdxgen_cmd(logger=self.logger)
        if not cdxgen_cmd:
            cdxgen_cmd = find_cdxgen_cmd(False, logger=self.logger)
        project_type_args = [f"-t {item}" for item in project_type]
        technique_args = [f"--technique {item}" for item in techniques]
        args = [cdxgen_cmd]
        args = args + project_type_args
        args = args + ["-o", self.bom_file]
        if technique_args:
            args = args + technique_args
        if options.get("deep"):
            args.append("--deep")
        if options.get("profile"):
            args.append("--profile")
            args.append(options.get("profile"))
        if options.get("cdxgen_args"):
            args += shlex.split(options.get("cdxgen_args"))
        if len(lifecycles) == 1 and lifecycles[0] == "pre-build":
            args += ["--lifecycle", "pre-build"]
        # Bug #233 - Source directory could be None when working with url
        if self.source_dir:
            args.append(self.source_dir)
        env = os.environ.copy()
        # Setup cdxgen thought logging
        if self.options.get("explain"):
            env["CDXGEN_THINK_MODE"] = "true"
        # Manage cdxgen temp directory
        cdxgen_temp_dir = None
        if not os.getenv("CDXGEN_TEMP_DIR"):
            cdxgen_temp_dir = tempfile.mkdtemp(prefix="cdxgen-temp-", dir=os.getenv("DEPSCAN_TEMP_DIR"))
            env["CDXGEN_TEMP_DIR"] = cdxgen_temp_dir
        if cdxgen_cmd:
            bom_result = exec_tool(args, self.source_dir if not any(
                t in project_type for t in ("docker", "oci", "container")) and self.source_dir and os.path.isdir(
                self.source_dir) else None, env, logger=self.logger)
        else:
            bom_result = BOMResult(success=False, command_output="Unable to locate cdxgen command.")
        if cdxgen_temp_dir:
            shutil.rmtree(cdxgen_temp_dir, ignore_errors=True)
        return bom_result


class CdxgenServerGenerator(CdxgenGenerator):
    """
    cdxgen generator that use a local cdxgen server for execution.
    """

    def generate(self) -> BOMResult:
        """
        Generate the BOM with cdxgen server.
        """
        cdxgen_server = options.get("cdxgen_server")
        if not cdxgen_server:
            return BOMResult(success=False,
                             command_output="Pass the `--cdxgen-server` argument to use the cdxgen server for BOM generation.")
        project_type = self.options.get("project_type", [])
        src_dir = self.source_dir
        if not src_dir and options.get("path"):
            src_dir = options.get("path")
        with httpx.Client(http2=True, base_url=cdxgen_server, timeout=180) as client:
            sbom_url = f"{cdxgen_server}/sbom"
            if self.logger:
                self.logger.debug("Invoking cdxgen server at %s", sbom_url)
            try:
                r = client.post(sbom_url,
                                json={**options, "url": options.get("url", ""), "path": options.get("path", src_dir),
                                      "type": options.get("project_type", ",".join(project_type)),
                                      "multiProject": options.get("multiProject", ""), }, headers=headers, )
                if r.status_code == httpx.codes.OK:
                    try:
                        json_response = r.json()
                        if json_response:
                            json_dump(bom_file, json_response, log=LOG)
                            return os.path.exists(bom_file)
                    except Exception as je:
                        return BOMResult(success=False,
                                         command_output=f"Unable to generate SBOM via cdxgen server due to {str(je)}")
                else:
                    return BOMResult(success=False,
                                     command_output=f"Unable to generate SBOM via cdxgen server due to {str(r.status_code)}")
            except Exception as e:
                if self.logger:
                    self.logger.error(e)
                return BOMResult(success=False,
                                 command_output="Unable to generate SBOM with cdxgen server. Trying to generate one locally.")


class CdxgenImageBasedGenerator(CdxgenGenerator):
    """
    cdxgen generator that use container images for execution.
    """

    def __init__(self, source_dir: str, bom_file: str, logger: Logger = None,
                 options: Optional[Dict[str, Any]] = None) -> None:
        super().__init__(source_dir, bom_file, logger, options)
        cdxgen_temp_dir = None
        if not os.getenv("CDXGEN_TEMP_DIR"):
            cdxgen_temp_dir = tempfile.mkdtemp(prefix="cdxgen-temp-", dir=os.getenv("DEPSCAN_TEMP_DIR"))
            os.environ["CDXGEN_TEMP_DIR"] = cdxgen_temp_dir
        self.cdxgen_temp_dir = cdxgen_temp_dir

    def _container_run_cmd(self) -> Tuple[str, List[str]]:
        """
        Generate a container run command for the given project type, source directory, and output file
        """
        project_type = self.options.get("project_type", [])
        techniques = self.options.get("techniques") or []
        lifecycles = self.options.get("lifecycles") or []
        image_output_dir = "/reports"
        app_input_dir = "/app"
        container_command = get_env_options_value(self.options, "DOCKER_CMD", "docker")
        image_name = get_image_for_type(self.options, project_type)
        run_command_args = [container_command, "run", "--rm", "--quiet", "--workdir", app_input_dir]
        output_file = os.path.basename(self.bom_file)
        output_dir = os.path.dirname(self.bom_file)
        # Setup environment variables
        for k, v in os.environ.items():
            if k.startswith("CDXGEN_") or k.startswith("GIT") or k in ("FETCH_LICENSE",):
                run_command_args += ["-e", k]
        run_command_args += ["-e", "CDXGEN_IN_CONTAINER=true"]
        # Do not repeat the sponsorship banner. Please note that cdxgen and depscan are separate projects, so they ideally require separate sponsorships.
        run_command_args += ["-e", "CDXGEN_NO_BANNER=true"]
        if os.getenv("SCAN_DEBUG_MODE") == "debug":
            run_command_args += ["-e", "CDXGEN_DEBUG_MODE=debug"]
        # Extra args like --platform=linux/amd64
        if os.getenv("DEPSCAN_DOCKER_ARGS"):
            run_command_args += os.getenv("DEPSCAN_DOCKER_ARGS", "").split(" ")
        # Setup volume mounts
        # Mount source directory as /app
        if os.path.isdir(self.source_dir):
            run_command_args += ["-v", f"{self.source_dir}:{app_input_dir}:rw"]
        else:
            run_command_args.append(self.source_dir)
        run_command_args += ["-v", f"{self.cdxgen_temp_dir}:/tmp:rw"]
        run_command_args += ["-v", f"{output_dir}:{image_output_dir}:rw"]
        # Mount the home directory as /root. Can be used for performance reasons.
        if self.options.get("insecure_mount_home"):
            run_command_args += ["-v", f"""{os.path.expanduser("~")}:/root:r"""]
        run_command_args.append(image_name)
        # output file mapped to the inside the image
        run_command_args += ["-o", f"{image_output_dir}/{output_file}"]
        # cdxgen args
        technique_args = [f"--technique {item}" for item in techniques]
        if technique_args:
            run_command_args += technique_args
        if self.options.get("profile"):
            run_command_args.append("--profile")
            run_command_args.append(self.options.get("profile"))
        if len(lifecycles) == 1 and lifecycles[0] == "pre-build":
            run_command_args += ["--lifecycle", "pre-build"]
        if self.options.get("deep"):
            run_command_args.append("--deep")
        if self.options.get("cdxgen_args"):
            run_command_args += shlex.split(self.options.get("cdxgen_args"))
        return image_name, run_command_args

    def generate(self) -> BOMResult:
        """
        Generate the BOM with official container images.
        """
        container_command = get_env_options_value(self.options, "DOCKER_CMD", "docker")
        if not shutil.which(container_command):
            return BOMResult(success=False,
                             command_output=f"{container_command} command not found. Pass `--bom-engine CdxgenGenerator` to force depscan to use the local cdxgen CLI.")
        image_name, run_command_args = self._container_run_cmd()
        if self.logger:
            self.logger.debug(f"Pulling the image {image_name}")
        exec_tool([container_command, "pull", "--quiet", image_name], logger=self.logger)
        if self.logger:
            self.logger.debug(f"Executing {' '.join(run_command_args)}")
        bom_result = exec_tool(run_command_args, cwd=None, env=os.environ.copy(), logger=self.logger)
        if self.cdxgen_temp_dir:
            shutil.rmtree(self.cdxgen_temp_dir, ignore_errors=True)
        return bom_result
