"""
Helper methods to unzip an archive preserving the file permissions. Python's zipfile module does not yet support
this feature natively (https://bugs.python.org/issue15795).
"""

import logging
import os
import zipfile

LOG = logging.getLogger(__name__)

S_IFLNK = 0xA


def _is_symlink(file_info):
    """
    Check the upper 4 bits of the external attribute for a symlink.
    See: https://unix.stackexchange.com/questions/14705/the-zip-formats-external-file-attribute

    Parameters
    ----------
    file_info : zipfile.ZipInfo
        The ZipInfo for a ZipFile

    Returns
    -------
    bool
        A response regarding whether the ZipInfo defines a symlink or not.
    """

    return (file_info.external_attr >> 28) == 0xA  # noqa: PLR2004


def _extract(file_info, output_dir, zip_ref, mount_symlinks=False):
    """
    Unzip the given file into the given directory while preserving file permissions in the process.

    Parameters
    ----------
    file_info : zipfile.ZipInfo
        The ZipInfo for a ZipFile
    output_dir : str
        Path to the directory where it should be unzipped to
    zip_ref : zipfile.ZipFile
        The ZipFile we are working with.
    mount_symlinks : bool
        If True, symlinks pointing outside the extraction directory are allowed.
        Default is False.

    Returns
    -------
    string
        Returns the target path the Zip Entry was extracted to.

    Raises
    ------
    ValueError
        If the extraction path is not valid
    """

    # Handle any regular file/directory entries
    if not _is_symlink(file_info):
        return zip_ref.extract(file_info, output_dir)

    source = zip_ref.read(file_info.filename).decode("utf8")
    output_dir = os.path.normpath(output_dir)
    link_name = os.path.normpath(os.path.join(output_dir, file_info.filename))

    output_dir_abs = os.path.abspath(output_dir)
    link_name_abs = os.path.abspath(link_name)

    # Validate that the symlink path itself is within the output directory
    if not link_name_abs.startswith(output_dir_abs + os.sep) and link_name_abs != output_dir_abs:
        raise ValueError(f"Failed to extract file from the zip file. The '{file_info.filename}' is invalid")

    # When mount_symlinks is disabled (default)
    if not mount_symlinks:
        if os.path.isabs(source):
            LOG.warning("Use --mount-symlinks to allow symlinks pointing outside the extraction directory.")
            raise ValueError(
                "Failed to extract file from the zip file. " "A symlink has an absolute target which is not allowed"
            )

        # For relative paths, validate that the resolved target stays within the extraction directory
        link_dir = os.path.dirname(link_name_abs)
        target_abs = os.path.abspath(os.path.join(link_dir, source))

        if not target_abs.startswith(output_dir_abs + os.sep) and target_abs != output_dir_abs:
            LOG.warning(
                "Symlink pointing outside the extraction directory. "
                "Use --mount-symlinks to allow symlinks pointing outside the extraction directory."
            )
            raise ValueError(
                "Failed to extract file from the zip file. " "A symlink points outside the extraction directory"
            )

    # make leading dirs if needed
    leading_dirs = os.path.dirname(link_name)
    if not os.path.exists(leading_dirs):
        os.makedirs(leading_dirs, exist_ok=True)

    # If the link already exists, delete it or symlink() fails
    if os.path.lexists(link_name):
        os.remove(link_name)

    # Create a symbolic link pointing to source named link_name.
    os.symlink(source, link_name)

    return link_name


def unzip(zip_file_path, output_dir, permission=None, mount_symlinks=False):
    """
    Unzip the given file into the given directory while preserving file permissions in the process.

    Parameters
    ----------
    zip_file_path : str
        Path to the zip file
    output_dir : str
        Path to the directory where it should be unzipped to
    permission : int
        Permission to set in an octal int form
    mount_symlinks : bool
        If True, symlinks pointing outside the extraction directory are allowed.
        This corresponds to the --mount-symlinks CLI option. Default is False.
    """
    extracted_path = None
    with zipfile.ZipFile(zip_file_path, "r") as zip_ref:
        # For each item in the zip file, extract the file and set permissions if available
        for file_info in zip_ref.infolist():
            try:
                extracted_path = _extract(file_info, output_dir, zip_ref, mount_symlinks)

                # If the extracted_path is a symlink, do not set the permissions. If the target of the symlink does not
                # exist, then os.chmod will fail with FileNotFoundError
                if not os.path.islink(extracted_path):
                    _set_permissions(file_info, extracted_path)
                    _override_permissions(extracted_path, permission)
            except ValueError:
                raise
            except Exception as ex:
                LOG.debug("Failed to extract '%s' from %s: %s", file_info.filename, zip_file_path, ex)

    if extracted_path is not None and not os.path.islink(extracted_path):
        _override_permissions(output_dir, permission)


def _override_permissions(path, permission):
    """
    Forcefully override the permissions on the path

    Parameters
    ----------
    path str
        Path where the file or directory
    permission octal int
        Permission to set

    """
    if permission:
        os.chmod(path, permission)


def _set_permissions(zip_file_info, extracted_path):
    """
    Sets permissions on the extracted file by reading the ``external_attr`` property of given file info.

    Parameters
    ----------
    zip_file_info : zipfile.ZipInfo
        Object containing information about a file within a zip archive

    extracted_path : str
        Path where the file has been extracted to
    """

    # Permission information is stored in first two bytes.
    permission = zip_file_info.external_attr >> 16
    if not permission:
        # Zips created on certain Windows machines, however, might not have any permission information on them.
        # Skip setting a permission on these files.
        LOG.debug("File %s in zipfile does not have permission information", zip_file_info.filename)
        return

    os.chmod(extracted_path, permission)
