import base64
import json
import logging
import os
import re
import shutil
import subprocess
import tempfile
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any

from labels.model.syft_sbom import (
    SyftAnnotation,
    SyftArtifact,
    SyftLocation,
    SyftMetadata,
    SyftRelationship,
    SyftSBOM,
    SyftSource,
)
from labels.utils.exceptions import (
    DockerImageNotFoundError,
    InvalidImageReferenceError,
    SyftNotFoundError,
    SyftOutputParsingError,
)

LOGGER = logging.getLogger(__name__)

ADDITIONAL_CATALOGERS = [
    "dart-pubspec-lock-cataloger",
    "dart-pubspec-cataloger",
    "swift-package-manager-cataloger",
    "cocoapods-cataloger",
]


def _get_syft_path() -> str:
    syft_path = shutil.which("syft")
    if not syft_path:
        raise SyftNotFoundError
    return syft_path


def _build_platform_arg(
    *,
    os_override: str | None = None,
    arch_override: str | None = None,
) -> list[str]:
    os_value = os_override or "linux"
    arch_value = arch_override or "arm64"
    return ["--platform", f"{os_value}/{arch_value}"]


def _get_formatted_image(*, image_ref: str, daemon: bool = False) -> str:
    image_ref_pattern = (
        r"^(?:(?P<host>[\w\.\-]+(?:\:\d+)?)/)?"
        r"(?P<namespace>(?:[\w\.\-]+(?:/[\w\.\-]+)*)?/)?"
        r"(?P<image>[\w\.\-]+)(?::(?P<tag>[\w\.\-]+))?(?:@"
        r"(?P<digest>sha256:[A-Fa-f0-9]{64}))?$"
    )

    syft_prefix = "docker" if daemon else "registry"
    prefixes = {"registry": "docker://", "docker": "docker-daemon:"}
    for prefix, prefix_value in prefixes.items():
        if image_ref.startswith(prefix_value):
            image_ref = image_ref.replace(prefix_value, "", 1)
            syft_prefix = prefix
            break

    if re.match(image_ref_pattern, image_ref):
        return f"{syft_prefix}:{image_ref}"

    raise InvalidImageReferenceError(image_ref)


@contextmanager
def _syft_auth_env(
    *,
    image_ref: str | None = None,
    username: str | None = None,
    password: str | None = None,
    aws_creds: str | None = None,
) -> Iterator[dict[str, str]]:
    if username and password:
        LOGGER.debug("Setting SYFT_REGISTRY_AUTH credentials for authenticated scan")
        yield {
            "SYFT_REGISTRY_AUTH_USERNAME": username,
            "SYFT_REGISTRY_AUTH_PASSWORD": password,
        }
        return

    if image_ref and aws_creds:
        config_dir = tempfile.mkdtemp(prefix=".syft")
        LOGGER.debug("Creating temporary Docker config dir at %s", config_dir)

        try:
            docker_config_path = Path(config_dir, "config.json")
            registry_url = image_ref.split("/", 1)[0]

            encoded_auth = base64.b64encode(aws_creds.encode("utf-8")).decode("utf-8")
            docker_config = {
                "auths": {
                    registry_url: {
                        "auth": encoded_auth,
                    },
                }
            }

            with docker_config_path.open("w", encoding="utf-8") as f:
                json.dump(docker_config, f)
            yield {
                "DOCKER_CONFIG": config_dir,
            }
        finally:
            LOGGER.debug("Cleaning up temporary DOCKER_CONFIG directory: %s", config_dir)
            shutil.rmtree(config_dir, ignore_errors=True)

        return

    yield {}


def _get_metadata(metadata: dict[str, Any]) -> SyftMetadata:  # type: ignore[explicit-any]
    dependencies = (
        metadata.get("depends", [])
        if metadata.get("depends")
        else metadata.get("pullDependencies", [])
    )
    release = metadata.get("release", "") if isinstance(metadata.get("release"), str) else ""
    package = str(metadata.get("package")) if metadata.get("package") else "UNKNOWN"
    version = str(metadata.get("version")) if metadata.get("version") else "UNKNOWN"
    source = metadata.get("source", "") if isinstance(metadata.get("source"), str) else ""

    return SyftMetadata(
        package=package,
        version=version,
        provides=metadata.get("provides", []),
        dependencies=dependencies,
        maintainer=metadata.get("maintainer", ""),
        origin_package=metadata.get("originPackage", ""),
        architecture=metadata.get("architecture", ""),
        source=source,
        source_version=metadata.get("sourceVersion", ""),
        pre_dependencies=metadata.get("preDepends", []),
        name=metadata.get("name", ""),
        epoch=metadata.get("epoch"),
        release=release,
        source_rpm=metadata.get("sourceRpm", ""),
        base_package=metadata.get("basepackage", ""),
        packager=metadata.get("packager", ""),
        requires_dist=metadata.get("requiresDist", []),
        provides_extra=metadata.get("providesExtra", []),
        manifest=metadata.get("manifest"),
        pom_properties=metadata.get("pomProperties", {}),
        pom_project=metadata.get("pomProject"),
    )


def _get_syft_source(source: dict[str, Any]) -> SyftSource:  # type: ignore[explicit-any]
    namespace = str(source.get("metadata", {}).get("userInput"))
    version = ""

    if (digests := source.get("metadata", {}).get("repoDigests")) and (first_digest := digests[0]):
        version = str(first_digest).split("@", 1)[1]

    return SyftSource(
        namespace=namespace,
        version=version,
    )


def _get_syft_relationships(relationships: list[dict[str, Any]]) -> list[SyftRelationship]:  # type: ignore[explicit-any]
    return [
        SyftRelationship(
            parent=parent,
            child=child,
            type=type_,
        )
        for relationship in relationships
        if (parent := relationship.get("parent"))
        and (child := relationship.get("child"))
        and (type_ := relationship.get("type"))
    ]


def _get_syft_artifacts(artifacts: list[dict[str, Any]]) -> list[SyftArtifact]:  # type: ignore[explicit-any]
    return [
        SyftArtifact(
            id_=str(artifact.get("id")),
            name=str(artifact.get("name")),
            version=str(artifact.get("version")),
            type=str(artifact.get("type")),
            locations=[
                SyftLocation(
                    path=location.get("path"),
                    access_path=location.get("path"),
                    layer_id=location.get("layerID"),
                    annotations=(
                        SyftAnnotation(
                            evidence=location.get("annotations", {}).get("evidence", ""),
                        )
                        if location.get("annotations")
                        else None
                    ),
                )
                for location in artifact.get("locations", [])
            ],
            p_url=str(artifact.get("purl")),
            metadata=_get_metadata(artifact.get("metadata", {})),
            found_by=str(artifact.get("foundBy")),
            language=str(artifact.get("language")),
        )
        for artifact in artifacts
        if artifact.get("id")
        and artifact.get("name")
        and artifact.get("version")
        and artifact.get("version") != "UNKNOWN"
    ]


def _parse_syft_output(raw: dict[str, Any], image_ref: str) -> SyftSBOM:  # type: ignore[explicit-any]
    if "artifacts" not in raw:
        raise SyftOutputParsingError(image_ref)

    return SyftSBOM(
        artifacts=_get_syft_artifacts(raw["artifacts"]),
        source=_get_syft_source(raw.get("source", {})),
        relationships=_get_syft_relationships(raw.get("artifactRelationships", [])),
    )


def get_docker_sbom(  # noqa: PLR0913
    image_ref: str,
    *,
    username: str | None = None,
    password: str | None = None,
    os_override: str | None = None,
    arch_override: str | None = None,
    aws_creds: str | None = None,
    daemon: bool = False,
) -> SyftSBOM:
    syft_path = _get_syft_path()
    formatted_img = _get_formatted_image(image_ref=image_ref, daemon=daemon)

    LOGGER.info("Retrieving SBOM for image: %s", image_ref)

    command_args = [
        syft_path,
        "scan",
        formatted_img,
        *_build_platform_arg(os_override=os_override, arch_override=arch_override),
        "--select-catalogers",
        ",".join("+" + cataloger for cataloger in ADDITIONAL_CATALOGERS),
        "-o",
        "json",
    ]

    with _syft_auth_env(
        image_ref=image_ref,
        username=username,
        password=password,
        aws_creds=aws_creds,
    ) as syft_envs:
        try:
            result = subprocess.run(  # noqa: S603
                command_args,
                check=True,
                capture_output=True,
                text=True,
                env={**os.environ, **syft_envs},
            )
        except subprocess.CalledProcessError as error:
            platform = f"{os_override or 'linux'}/{arch_override or 'arm64'}"
            LOGGER.exception(
                "Failed to scan image %s for platform %s. "
                "Ensure the image exists and use --os and --arch "
                "to specify a different platform.",
                image_ref,
                platform,
            )
            raise DockerImageNotFoundError(image_ref, error.stderr.strip()) from error

    return _parse_syft_output(json.loads(result.stdout), image_ref)
