#  pylint: disable=missing-module-docstring,unused-import

# SPDX-FileCopyrightText: 2018-2025 SUSE LLC
#
# SPDX-License-Identifier: Apache-2.0

import salt.exceptions
import logging
import os
import re
import json

log = logging.getLogger(__name__)

# __salt__ variable is populated with Salt Lazy Loader.
# Define it for testing purposes and to avoid pylint warnings.
__salt__ = {}

# Kiwi version is always in format "MAJOR.MINOR.RELEASE" with numeric values
# Source https://osinside.github.io/kiwi/image_description/elements.html#preferences-version
# Enhanced by allowing also MAJOR.MINOR version, particularly for SL-Micro images
KIWI_VERSION_REGEX = r"\d+\.\d+(\.\d+)?"
# Taken from Kiwi sources https://github.com/OSInside/kiwi/blob/eb2b1a84bf7/kiwi/schema/kiwi.rng#L81
KIWI_ARCH_REGEX = r"(x86_64|i586|i686|ix86|aarch64|arm64|armv5el|armv5tel|armv6hl|armv6l|armv7hl|armv7l|ppc|ppc64|ppc64le|s390|s390x|riscv64)"
# Taken from Kiwi sources https://github.com/OSInside/kiwi/blob/eb2b1a84bf7/kiwi/schema/kiwi.rng#L26
KIWI_NAME_REGEX = r"[a-zA-Z0-9_\-\.]+"


def parse_profile(chroot):
    ret = {}
    path = os.path.join(chroot, "image", ".profile")
    if __salt__["file.file_exists"](path):
        profile = __salt__["cp.get_file_str"](path)
        pattern = re.compile(r"^(?P<name>[^=]+?)='(?P<val>.*)'")
        for line in profile.splitlines():
            match = pattern.match(line)
            if match:
                ret[match.group("name")] = match.group("val")
    return ret


def parse_buildinfo(dest):
    ret = {}
    path = os.path.join(dest, "kiwi.buildinfo")
    if __salt__["file.file_exists"](path):
        profile = __salt__["cp.get_file_str"](path)
        pattern_group = re.compile(r"^\[(?P<name>.*)\]")
        pattern_val = re.compile(r"^(?P<name>.*?)=(?P<val>.*)")

        group = ret
        for line in profile.splitlines():
            match = pattern_group.match(line)
            if match:
                group = {}
                ret[match.group("name")] = group

            match = pattern_val.match(line)
            if match:
                group[match.group("name")] = match.group("val")
    return ret


# fallback for SLES11 Kiwi and for Kiwi NG that does not create the buildinfo file
def guess_buildinfo(dest):
    ret = {"main": {}}
    files = __salt__["file.readdir"](dest)

    pattern_basename = re.compile(r"^(?P<basename>.*)\.packages$")
    pattern_pxe_initrd = re.compile(r"^initrd-netboot.*")
    pattern_pxe_kiwi_ng_initrd = re.compile(r".*\.initrd\..*")
    pattern_pxe_kernel = re.compile(r".*\.kernel\..*")
    pattern_pxe_kiwi_ng_kernel = re.compile(r".*\.kernel$")
    have_kernel = False
    have_initrd = False

    for f in files:
        match = pattern_basename.match(f)
        if match:
            ret["main"]["image.basename"] = match.group("basename")

        match = pattern_pxe_initrd.match(f) or pattern_pxe_kiwi_ng_initrd.match(f)
        if match:
            have_initrd = True

        match = pattern_pxe_kernel.match(f) or pattern_pxe_kiwi_ng_kernel.match(f)
        if match:
            have_kernel = True

    if have_kernel and have_initrd:
        ret["main"]["image.type"] = "pxe"
    return ret


# Kiwi NG
_kiwi_result_script = """
import sys
import pickle
import json
ret = {}
with open(sys.argv[1], 'rb') as f:
    result = pickle.load(f)
    ret['arch'] = result.xml_state.host_architecture
    ret['basename'] = result.xml_state.xml_data.name
    ret['type'] = result.xml_state.build_type.image
    ret['filesystem'] = result.xml_state.build_type.filesystem
    ret['initrd_system'] = result.xml_state.build_type.initrd_system
    print(json.dumps(ret))
"""


def parse_kiwi_result(dest):
    path = os.path.join(dest, "kiwi.result")
    ret = {}
    if __salt__["file.file_exists"](path):
        # pickle depends on availability of python kiwi modules
        # which are not under our control so there is certain risk of failure
        # also, the kiwi libraries may not be available in salt bundle
        # -> parse the file via wrapper script using system python3
        #
        # return empty dict on failure
        # the caller should handle all values as optional
        for python in ["/usr/bin/python3.11", "/usr/bin/python3"]:
            if __salt__["file.file_exists"](python):
                result = __salt__["cmd.exec_code_all"](
                    python, _kiwi_result_script, args=[path]
                )
                if result["retcode"] == 0:
                    ret = json.loads(result["stdout"])
                    break
        # else return empty dict

    return ret


def parse_packages(path):
    ret = []
    if __salt__["file.file_exists"](path):
        packages = __salt__["cp.get_file_str"](path)
        fields = ["name", "epoch", "version", "release", "arch", "disturl", "license"]
        for line in packages.splitlines():
            line_data = line.split("|")
            if len(line_data) == len(fields):
                # translate '(none)' values to ''
                d = dict(zip(fields, line_data))
                for k in list(d.keys()):
                    if d[k] == "(none)":
                        d[k] = ""

                # if arch is '' and name begins gpg-pubkey then skip the package
                if d["arch"] == "" and d["name"].startswith("gpg-pubkey"):
                    continue

                ret.append(d)
    return ret


def get_md5(path):
    res = {}
    if not __salt__["file.file_exists"](path):
        return res

    res["hash"] = "md5:" + __salt__["file.get_hash"](path, form="md5")
    res["size"] = __salt__["file.stats"](path).get("size")
    return res


def get_decompressed_md5(path, decompress_cmd):
    res = {}
    # pylint: disable-next=undefined-variable
    if not __salt__["file.file_exists"](path):
        return res

    cmd = decompress_cmd + " '" + path + "' | md5sum"
    # pylint: disable-next=undefined-variable
    shell_result = __salt__["cmd.run_all"](cmd, python_shell=True)

    if shell_result["retcode"] == 0:
        hash_val = shell_result["stdout"].split()[0].strip()
        res["hash"] = "md5:" + hash_val

    return res


def parse_kiwi_hash(path, compressed=False, hash_type="md5"):
    res = {}

    if not __salt__["file.file_exists"](path):
        return res

    hash_str = __salt__["cp.get_file_str"](path)
    if hash_str is not None:
        if compressed:
            pattern = re.compile(
                r"^(?P<hash>[0-9a-f]+)\s+(?P<size1>[0-9]+)\s+(?P<size2>[0-9]+)\s+(?P<csize1>[0-9]+)\s+(?P<csize2>[0-9]+)\s*$"
            )
        else:
            pattern = re.compile(
                r"^(?P<hash>[0-9a-f]+)\s+(?P<size1>[0-9]+)\s+(?P<size2>[0-9]+)\s*$"
            )
        match = pattern.match(hash_str)
        if match:
            res["hash"] = hash_type + ":" + match.group("hash")
            res["size"] = int(match.group("size1")) * int(match.group("size2"))
            if compressed:
                res["compressed_size"] = int(match.group("csize1")) * int(
                    match.group("csize2")
                )
    return res


_compression_types = {
    ".gz": "gzip",
    ".bz": "bzip",
    ".xz": "xz",
    "": None,
}


_decompress_cmd = {
    "gzip": "gzip -dc",
    "bzip": "bzip2 -dc",
    "xz": "xz -dc",
}


# suffixes for pxe/kis image type
_pxe_image_types = [
    ".gz",
    ".bz",
    ".xz",
    "",
]


def _known_image_types():
    formats = _disk_format_types()
    formats.extend(_compressed_format_types())
    formats.extend(_iso_format_types())
    formats.extend(_raw_format_types())
    return formats


# from https://github.com/OSInside/kiwi/blob/main/kiwi/defaults.py#L1501
def _disk_format_types():
    return [
        ".gce",
        ".qcow2",
        ".vmdk",
        ".ova",
        ".vmx",
        ".vhd",
        ".vhdx",
        ".vhdfixed",
        ".vdi",
        ".vagrant.libvirt.box",
        ".vagrant.virtualbox.box",
    ]


def _compressed_format_types():
    return [".gz", ".bz", ".xz", ".tar.xz"]


def _iso_format_types():
    return [".install.iso", ".iso"]


def _raw_format_types():
    return [".raw", ".squashfs"]


def image_details(dest, bundle_dest=None):
    """
    Gather detailed information about system image.
    """
    res = {}
    buildinfo = parse_buildinfo(dest) or guess_buildinfo(dest)
    kiwiresult = parse_kiwi_result(dest)

    basename = buildinfo.get("main", {}).get("image.basename", "")
    image_type = kiwiresult.get("type") or buildinfo.get("main", {}).get(
        "image.type", "unknown"
    )
    fstype = kiwiresult.get("filesystem")

    pattern = re.compile(
        # pylint: disable-next=consider-using-f-string
        r"^(?P<name>{})\.(?P<arch>{})-(?P<version>{})$".format(
            KIWI_NAME_REGEX, KIWI_ARCH_REGEX, KIWI_VERSION_REGEX
        )
    )
    match = pattern.match(basename)
    if match:
        name = match.group("name")
        arch = match.group("arch")
        version = match.group("version")
    else:
        return None

    filename = None
    filepath = None
    compression = None
    image_types = _known_image_types()
    if image_type == "pxe" or image_type == "kis":
        image_types = _pxe_image_types

    for c in image_types:
        path = os.path.join(dest, basename + c)
        if __salt__["file.file_exists"](path):
            filename = basename + c
            filepath = path
            compression = _compression_types.get(c, None)
            break

    res["image"] = {
        "basename": basename,
        "name": name,
        "arch": arch,
        "type": image_type,
        "version": version,
        "filename": filename,
        "filepath": filepath,
        "fstype": fstype,
    }
    if compression:
        res["image"].update(
            {
                "compression": compression,
                "compressed_hash": __salt__["hashutil.digest_file"](
                    filepath, checksum="md5"
                ),
            }
        )

    res["image"].update(
        parse_kiwi_hash(
            os.path.join(dest, basename + ".sha256"), compression is not None, "sha256"
        )
    )
    res["image"].update(
        parse_kiwi_hash(
            os.path.join(dest, basename + ".md5"), compression is not None, "md5"
        )
    )

    # remove this when saltboot supports sha256:
    if "hash" not in res["image"] or not res["image"]["hash"].startswith("md5:"):
        if compression:
            decompress_cmd = _decompress_cmd[compression]
            res["image"].update(get_decompressed_md5(filepath, decompress_cmd))
        else:
            res["image"].update(get_md5(filepath))

    if bundle_dest is not None:
        res["bundles"] = inspect_bundles(bundle_dest, basename)

    return res


def inspect_image(dest, build_id, bundle_dest=None):
    """
    Image inspection stage entrypoint.
    Provides detailed information about image and packages it contains.
    """
    res = image_details(dest, bundle_dest)
    if not res:
        return None

    res["image"]["build_id"] = build_id

    basename = res["image"]["basename"]
    image_type = res["image"]["type"]

    for fstype in ["ext2", "ext3", "ext4", "btrfs", "xfs"]:
        path = os.path.join(dest, basename + "." + fstype)
        if __salt__["file.file_exists"](path) or __salt__["file.is_link"](path):
            res["image"]["fstype"] = fstype
            break

    res["packages"] = parse_packages(os.path.join(dest, basename + ".packages"))

    if image_type == "pxe":
        res["boot_image"] = inspect_boot_image(dest)

    return res


def inspect_boot_image(dest):
    """
    Gather information about boot image (kernel and initrd).
    Only valid for PXE/KIS image type.
    """
    res = None
    files = __salt__["file.readdir"](dest)

    pattern = re.compile(
        # pylint: disable-next=consider-using-f-string
        r"^(?P<name>{})\.(?P<arch>{})-(?P<version>{})\.kernel\.(?P<kernelversion>.*)\.md5$".format(
            KIWI_NAME_REGEX, KIWI_ARCH_REGEX, KIWI_VERSION_REGEX
        )
    )
    pattern_kiwi_ng = re.compile(
        # pylint: disable-next=consider-using-f-string
        r"^(?P<name>{})\.(?P<arch>{})-(?P<version>{})-(?P<kernelversion>.*)\.kernel$".format(
            KIWI_NAME_REGEX, KIWI_ARCH_REGEX, KIWI_VERSION_REGEX
        )
    )
    for f in files:
        match = pattern.match(f)
        if match:
            basename = (
                match.group("name")
                + "."
                + match.group("arch")
                + "-"
                + match.group("version")
            )
            res = {
                "name": match.group("name"),
                "arch": match.group("arch"),
                "basename": basename,
                "initrd": {"version": match.group("version")},
                "kernel": {"version": match.group("kernelversion")},
                "kiwi_ng": False,
            }
            break
        match = pattern_kiwi_ng.match(f)
        if match:
            basename = (
                match.group("name")
                + "."
                + match.group("arch")
                + "-"
                + match.group("version")
            )
            res = {
                "name": match.group("name"),
                "arch": match.group("arch"),
                "basename": basename,
                "initrd": {"version": match.group("version")},
                "kernel": {"version": match.group("kernelversion")},
                "kiwi_ng": True,
            }
            break

    if res is None:
        return None

    for c in _compression_types:
        if res["kiwi_ng"]:
            file = basename + ".initrd" + c
        else:
            file = basename + c
        filepath = os.path.join(dest, file)
        if __salt__["file.file_exists"](filepath):
            res["initrd"]["filename"] = file
            res["initrd"]["filepath"] = filepath
            if res["kiwi_ng"]:
                res["initrd"].update(get_md5(filepath))
            else:
                res["initrd"].update(
                    parse_kiwi_hash(os.path.join(dest, basename + ".md5"))
                )
            break

    if res["kiwi_ng"]:
        file = basename + "-" + res["kernel"]["version"] + ".kernel"
        filepath = os.path.join(dest, file)
        if __salt__["file.file_exists"](filepath):
            res["kernel"]["filename"] = file
            res["kernel"]["filepath"] = filepath
            res["kernel"].update(get_md5(filepath))
    else:
        file = basename + ".kernel." + res["kernel"]["version"]
        filepath = os.path.join(dest, file)
        if __salt__["file.file_exists"](filepath):
            res["kernel"]["filename"] = file
            res["kernel"]["filepath"] = filepath
            res["kernel"].update(parse_kiwi_hash(filepath + ".md5"))

    return res


def inspect_bundles(dest, basename):
    """
    Gather details about image bundle.
    Image bundle is a compressed tarball of all image results with custom naming.

    Not used by default, not compatible with containerized saltboot workflow.
    """
    res = []
    files = __salt__["file.readdir"](dest)

    pattern = re.compile(
        r"^(?P<basename>"
        + re.escape(basename)
        + r")-(?P<id>[^.]*)\.(?P<suffix>.*)\.sha256$"
    )
    for f in files:
        match = pattern.match(f)
        if match:
            res1 = match.groupdict()
            sha256_file = f
            sha256_str = __salt__["cp.get_file_str"](os.path.join(dest, sha256_file))
            pattern2 = re.compile(r"^(?P<hash>[0-9a-f]+)\s+(?P<filename>\S.*)$")
            match = pattern2.match(sha256_str)
            if match:
                d = match.groupdict()
                d["filename"] = d["filename"].strip()
                d["hash"] = f'sha256:{d["hash"]}'
                res1.update(d)
                res1["filepath"] = os.path.join(dest, res1["filename"])
            else:
                # only hash without file name
                pattern2 = re.compile(r"^(?P<hash>[0-9a-f]+)$")
                match = pattern2.match(sha256_str)
                if match:
                    res1["hash"] = f'sha256:{match.groupdict()["hash"]}'
                    res1["filename"] = sha256_file[0 : -len(".sha256")]
                    res1["filepath"] = os.path.join(dest, res1["filename"])
            res.append(res1)
    return res


def build_info(dest, build_id, bundle_dest=None):
    """
    Generates basic build info for image collection. Skips package inspection.
    """
    res = {}
    buildinfo = parse_buildinfo(dest) or guess_buildinfo(dest)
    kiwiresult = parse_kiwi_result(dest)
    basename = buildinfo.get("main", {}).get("image.basename", "")
    image_type = kiwiresult.get("type") or buildinfo.get("main", {}).get(
        "image.type", "unknown"
    )

    pattern = re.compile(
        # pylint: disable-next=consider-using-f-string
        r"^(?P<name>{})\.(?P<arch>{})-(?P<version>{})$".format(
            KIWI_NAME_REGEX, KIWI_ARCH_REGEX, KIWI_VERSION_REGEX
        )
    )
    match = pattern.match(basename)
    if not match:
        log.error("Unable to match Kiwi results")
        return None

    name = match.group("name")
    arch = match.group("arch")
    version = match.group("version")

    image_filepath = None
    image_filename = None
    image_types = _known_image_types()

    if image_type == "pxe" or image_type == "kis":
        r = inspect_boot_image(dest)
        res["boot_image"] = {
            "initrd": {
                "filepath": r["initrd"]["filepath"],
                "filename": r["initrd"]["filename"],
                "hash": r["initrd"]["hash"],
            },
            "kernel": {
                "filepath": r["kernel"]["filepath"],
                "filename": r["kernel"]["filename"],
                "hash": r["kernel"]["hash"],
            },
        }
        image_types = _pxe_image_types

    for c in image_types:
        test_name = basename + c
        filepath = os.path.join(dest, test_name)
        if __salt__["file.file_exists"](filepath):
            image_filename = test_name
            image_filepath = filepath
            break

    res["image"] = {
        "name": name,
        "arch": arch,
        "version": version,
        "filepath": image_filepath,
        "filename": image_filename,
        "build_id": build_id,
    }

    # Kiwi creates checksum for filesystem image when image type is PXE(or KIS), however if image is compressed, this
    # checksum is of uncompressed image. Other image types do not have checksum created at all.
    res["image"].update(get_md5(image_filepath))

    if bundle_dest is not None:
        res["bundles"] = inspect_bundles(bundle_dest, basename)

    return res
