import os
import re
import subprocess
import sys
from pathlib import Path
from typing import Tuple, List, Union, Dict, Any, Set

from cx_Freeze.freezer import (
    WinFreezer as CxWinFreezer,
    LinuxFreezer as CxLinuxFreezer,
    DarwinFreezer as CxDarwinFreezer,
)
from cx_Freeze.patchelf import Patchelf as CxPatchelf, NON_ELF_EXT, MAGIC_ELF


class FreezerMixin(object):
    def _pre_copy_hook(self, source: Path, target: Path) -> Tuple[Path, Path]:
        return Path(os.path.abspath(source)), Path(os.path.abspath(target))


if sys.platform.startswith("win"):

    class WinFreezer(FreezerMixin, CxWinFreezer):
        def __new__(cls, *args, **kwargs):
            return object.__new__(WinFreezer)

        def __init__(self, *args, **kwargs):
            CxWinFreezer.__init__(self, *args, **kwargs)

        def _post_copy_hook(
            self, source: Path, target: Path, copy_dependent_files: bool, include_mode: bool = False
        ) -> None:
            if copy_dependent_files and source not in self.finder.exclude_dependent_files:
                for dependent_file in CxWinFreezer._get_dependent_files(self, source):
                    target = Path(self.targetdir, os.path.basename(dependent_file))
                    if target.exists():
                        if target.read_bytes() != Path(dependent_file).read_bytes():
                            if dependent_file.as_posix().lower().endswith(
                                "/wx/vcruntime140.dll"
                            ) or dependent_file.as_posix().lower().endswith("/pyzmq.libs/msvcp140.dll"):
                                continue  # TODO: define this better
                            raise Exception(
                                f"Dependant files conflict!\n"
                                f"  Dependency source: {source}\n"
                                f"  Copy target path : {target}\n"
                                f"  New source path  : {dependent_file}"
                            )
                    else:
                        self._copy_file(dependent_file, target, copy_dependent_files)

    Freezer = WinFreezer

elif sys.platform.startswith("linux"):

    class Patchelf(CxPatchelf):
        def is_elf(self, path: Union[str, Path]) -> bool:
            """
            Do not exclude symlinks, that link to ELF files.
            """
            if isinstance(path, str):
                path = Path(path)
            if path.suffix in NON_ELF_EXT or not path.is_file():
                return False
            with open(path, "rb") as fp:
                four_bytes = fp.read(4)
            return bool(four_bytes == MAGIC_ELF)

        def get_needed(
            self, path: Union[str, Path], linker_warnings: Dict[Path, Any], show_warnings: bool
        ) -> Set[Path]:
            """
            Allow processing of files without executable permission.
            """
            dependent_files: Set[Path] = set()
            if isinstance(path, str):
                path = Path(path)
            if not self.is_elf(path):
                return dependent_files
            split_string = " => "
            dependent_file_index = 1
            args = ("ldd", path)
            process = subprocess.run(args, encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            for line in process.stdout.splitlines():
                parts = line.expandtabs().strip().split(split_string)
                if len(parts) != 2:
                    continue
                dependent_file = parts[dependent_file_index].strip()
                if dependent_file == path.name:
                    continue
                if dependent_file in ("not found", "(file not found)"):
                    filename = parts[0]
                    if filename not in linker_warnings:
                        linker_warnings[filename] = None
                        if show_warnings:
                            print(f"WARNING: cannot find '{filename}'")
                    continue
                if dependent_file.startswith("("):
                    continue
                pos = dependent_file.find(" (")
                if pos >= 0:
                    dependent_file = dependent_file[:pos].strip()
                if dependent_file:
                    dependent_files.add(Path(dependent_file))
            if process.returncode and show_warnings:
                print("WARNING:", *args, "returns:")
                print(process.stderr, end="")
            return dependent_files

    class LinuxFreezer(FreezerMixin, CxLinuxFreezer):

        GLIBC_RE = re.compile(
            r"^((ld|ld-linux-x86-64|libanl|libc|libcidn|libcrypt|libdl|libm|libnsl|libnss_compat|libnss_db|libnss_dns|"
            r"libnss_files|libnss_hesiod|libnss_nis|libnss_nisplus|libpthread|libresolv|librt|libthread_db|libutil)"
            r"(-\d+\.\d+)?\.so(\.\d+)?)$",
        )

        def __new__(cls, *args, **kwargs):
            return object.__new__(LinuxFreezer)

        def __init__(self, *args, **kwargs):
            CxLinuxFreezer.__init__(self, *args, **kwargs)
            self.patchelf = Patchelf()

        def _should_copy_file(self, path: Path) -> bool:
            if self.GLIBC_RE.match(path.name):
                return False
            return CxLinuxFreezer._should_copy_file(self, path)

        def _default_bin_path_excludes(self) -> List[str]:
            # NOTE: The available libs in Linux are so varied that we opt to bundle everything. (except glibc)
            return []

        def _post_copy_hook(self, source: Path, target: Path, copy_dependent_files: bool, include_mode: bool = False):
            if copy_dependent_files and source not in self.finder.exclude_dependent_files:
                self._copy_patchelf(source, target)

        def _copy_patchelf(self, source: Path, target: Path):
            fix_rpath = set()
            for dependent_file in self._get_dependent_files(source):
                dependent_file = Path(dependent_file)
                if dependent_file.parent == source.parent or dependent_file.parent == target.parent:
                    dep_target = target.parent
                    fix_rpath.add("$ORIGIN")
                else:
                    dep_target = self.targetdir / "lib"
                    fix_rpath.add(
                        str(Path("$ORIGIN", "/".join([".."] * len(target.parent.relative_to(dep_target).parts))))
                    )
                dep_target = dep_target / dependent_file.name
                if dep_target.exists():
                    pass
                    # TODO: we can't check if the so is truly the same, because patchelf changes its content
                    # if dep_target.read_bytes() != dependent_file.read_bytes():
                    #     raise Exception(
                    #         f"Dependant files conflict!\n"
                    #         f"  Dependency source: {source}\n"
                    #         f"  Copy target path : {dep_target}\n"
                    #         f"  New source path  : {dependent_file}"
                    #     )
                else:
                    self._copy_file(dependent_file, dep_target, True)
            if fix_rpath:
                has_rpath = self.patchelf.get_rpath(target)
                rpath = ":".join(sorted(fix_rpath))
                if has_rpath != rpath:
                    self.patchelf.set_rpath(target, rpath)

        def _write_modules(self, filename, finder):
            CxLinuxFreezer._write_modules(self, filename, finder)
            # fix rpath of elf files that were copied as module data
            for dir_path, _, file_names in os.walk(self.targetdir / "lib"):
                dir_path = Path(dir_path)
                for file_name in file_names:
                    file_path = dir_path / file_name
                    if self.patchelf.is_elf(file_path):
                        self._copy_patchelf(file_path, file_path)

    Freezer = LinuxFreezer

elif sys.platform.startswith("darwin"):

    class DarwinFreezer(FreezerMixin, CxDarwinFreezer):
        def __new__(cls, *args, **kwargs):
            return object.__new__(DarwinFreezer)

        def __init__(self, *args, **kwargs):
            CxDarwinFreezer.__init__(self, *args, **kwargs)

        def _post_freeze_hook(self):
            CxDarwinFreezer._post_freeze_hook(self)
            # cx-Freeze collect dylibs used by the application and places them next to the executable.
            # Those are used, the ones in the python libraries are not.
            for root, _, files in os.walk(Path(self.targetdir, "lib")):
                rm = False
                for file in files:
                    if file.endswith(".dylib"):
                        print("RM DUP", file, root)
                        rm = True
                        os.unlink(os.path.join(root, file))
                if rm and not list(Path(root).iterdir()):
                    os.rmdir(root)

    Freezer = DarwinFreezer

else:
    raise NotImplementedError()
