import itertools
import os
import re
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional

from cx_Freeze import bdist_dmg, bdist_mac
from cx_Freeze.darwintools import changeLoadReference, applyAdHocSignature, DarwinFile
from macholib.MachO import MachO

from dev_tools.build.freeze.cx import NanoBuildOptions
from dev_tools.build.freeze.cx.verify import BundlingVerifier
from dev_tools.build.sign.identity import SIGNING_IDENTITY_NAME

HERE = os.path.abspath(os.path.dirname(__file__))


class bdist_dmg_nano(bdist_dmg):
    def copy_tree(self, infile, outfile):
        # we need to preserve symlinks
        bdist_dmg.copy_tree(self, infile, outfile, preserve_mode=1, preserve_times=1, preserve_symlinks=1, level=1)


class bdist_mac_nano(bdist_mac):

    FILE_TYPE_EXE = "execute"
    FILE_TYPE_LIB = "dylib"
    SIGNATURE_RE = re.compile(r"^Signature=(.+)$", re.MULTILINE)
    AUTHORITY_RE = re.compile(r"^Authority=(.+)$", re.MULTILINE)

    def finalize_options(self):
        bdist_mac.finalize_options(self)
        if self.codesign_identity:
            raise Exception("Do not use the codesign_identity option")

    def run(self):
        self.restructured = False
        build = self.get_finalized_command("build")
        bundle_dir = Path(os.path.join(build.build_base, self.bundle_name + ".app")).resolve()
        if bundle_dir.exists():
            shutil.rmtree(bundle_dir)

        bdist_mac.run(self)

        self._restructure_bundle()
        self.restructured = True

        self.thinAllLibs(os.path.abspath(build.build_exe))

        self.setRelativeReferencePaths(os.path.abspath(build.build_exe), os.path.abspath(self.binDir))
        BundlingVerifier(Path(bundle_dir), self.get_finalized_command("build_exe").freezer.bin_path_excludes).run()

        if not NanoBuildOptions.SKIP_SIGNING:
            # A signing certificate is needed. With "Keychain Access" use the wizard "Request a Certificate from
            # Certificate Authority". The common name shall be "Clarabot Zrt". Save it to disk. Log in to the
            # "developer.apple.com" site to access the company certificate manager ui. The New certificate must be
            # of type "Developer ID Application", no services are needed. Continue, upload, download and add signed
            # certificate to the local machine.
            # To store the app-id for signing with notarytool:
            #   xcrun notarytool store-credentials --apple-id "username" --team-id "team-id"
            # Or with altool:
            #   xcrun altool --store-password-in-keychain-item Notarizetool -u "username" -p "app-password"
            # For the legacy altool the NOTARIZE_KEYCHAIN_ACCOUNT env-var shall also be set.
            notarize_keychain_profile = os.environ.get("NOTARIZE_KEYCHAIN_PROFILE", "Notarizetool")

            self._sign()
            self._verify_sign()

            self._notarize(notarize_keychain_profile)
            self._assess_notarization()

    def setRelativeReferencePaths(self, buildDir: str, binDir: str):
        """
        Make all the references from included Mach-O files to other included
        Mach-O files relative.
        """
        if not self.restructured:
            return

        darwinFile: DarwinFile

        for darwinFile in self.darwinTracker:
            # get the relative path to darwinFile in build directory
            relativeCopyDestination = os.path.relpath(darwinFile.getBuildPath(), buildDir)
            # figure out directory where it will go in binary directory for
            # .app bundle, this would be the Content/MacOS subdirectory in
            # bundle.  This is the file that needs to have its dynamic load
            # references updated.
            filePathInBinDir = os.path.join(binDir, relativeCopyDestination)

            # for each file that this darwinFile references, update the
            # reference as necessary; if the file is copied into the binary
            # package, change the reference to be relative to @executable_path
            # (so an .app bundle will work wherever it is moved)
            for reference in darwinFile.getMachOReferenceList():
                if not reference.is_copied:
                    # referenced file not copied -- assume this is a system
                    # file that will also be present on the user's machine,
                    # and do not change reference
                    continue
                # this is the reference in the machO file that needs to be
                # updated
                rawPath = reference.raw_path
                referencedDarwinFile: DarwinFile = reference.target_file
                # this is where file copied in build dir
                absoluteBuildDest = referencedDarwinFile.getBuildPath()
                relativeBuildDest = os.path.relpath(absoluteBuildDest, buildDir)
                while not os.path.isfile(os.path.join(buildDir, relativeBuildDest)):
                    filename = os.path.basename(relativeBuildDest)
                    dirname = os.path.dirname(relativeBuildDest)
                    if not dirname:
                        raise Exception(f"Not found in build dir: {os.path.relpath(absoluteBuildDest, buildDir)}")
                    relativeBuildDest = os.path.join(os.path.dirname(dirname), filename)
                exePath = f"@executable_path/{relativeBuildDest}"
                changeLoadReference(
                    filePathInBinDir,
                    oldReference=rawPath,
                    newReference=exePath,
                    VERBOSE=False,
                )

            applyAdHocSignature(filePathInBinDir)

    def _sign(self):
        """
        We must sign all native code object "inside-out".
        """
        bundle_dir = os.path.realpath(self.bundleDir)
        kernel_version = int(os.uname().release.partition(".")[0])
        if kernel_version < 19:
            jre_entitlements_path = str(Path(HERE, "entitlements_10_14.plist"))
        else:
            jre_entitlements_path = str(Path(HERE, "entitlements_10_15.plist"))
        sign_args = ["codesign", "--sign", SIGNING_IDENTITY_NAME, "--timestamp", "--options", "runtime", "-f"]

        def _signature(file_path):
            try:
                o_ = subprocess.check_output(["codesign", "-dvv", file_path], stderr=subprocess.STDOUT, text=True)
            except subprocess.CalledProcessError as e:
                if e.stdout.find(": code object is not signed at all") >= 0:
                    return "not signed"
                raise e
            m_ = self.SIGNATURE_RE.search(o_)
            if m_:
                return m_.group(1)
            m_ = self.AUTHORITY_RE.search(o_)
            if m_:
                return m_.group(1)
            raise Exception(f"Unexpected signature state: {o_}")

        print("Collecting files for signing...")
        jre_exes = []
        sign_exes = []
        sign_libs = []
        for root, _, files in os.walk(bundle_dir, followlinks=False):
            for file in files:
                p = Path(root, file)
                macho_type = self._get_binary_type(p)
                if not macho_type:
                    continue
                p = str(p)
                s = _signature(p)
                if s == SIGNING_IDENTITY_NAME:
                    print("Already signed by us:", p)
                    continue
                else:
                    if s in ("adhoc", "not signed"):
                        print("Selected for signing:", p)
                    else:
                        print("Replacing signature of", s, ":", p)
                    if macho_type == self.FILE_TYPE_EXE:
                        if p.find("/jre/") >= 0:
                            jre_exes.append(p)
                        else:
                            sign_exes.append(p)
                    else:
                        sign_libs.append(p)

        # Sign executables in bundled JRE.
        subprocess.check_call([*sign_args, "--entitlements", jre_entitlements_path, *jre_exes])
        # Sign files in bundle.
        subprocess.check_call([*sign_args, *sign_libs, *sign_exes])
        # Sign bundle.
        subprocess.check_call([*sign_args, bundle_dir])

    def _verify_sign(self):
        bundle_dir = os.path.realpath(self.bundleDir)
        subprocess.check_call(["codesign", "-v", "-vvv", "--deep", "--strict", bundle_dir])

    def _notarize(self, keychain_profile):
        bundle_dir = Path(os.path.realpath(self.bundleDir))
        bundle_zip = bundle_dir.parent / bundle_dir.name.replace(".app", ".zip")
        bundle_zip.unlink(missing_ok=True)
        subprocess.check_call(["ditto", "-c", "-k", "--keepParent", str(bundle_dir), str(bundle_zip)])
        self._call_notarization_service(str(bundle_zip), keychain_profile)
        subprocess.check_call(["xcrun", "stapler", "staple", str(bundle_dir)])

    def _call_notarization_service(self, bundle_zip, keychain_profile):
        try:
            subprocess.check_call(["xcrun", "notarytool", "--help"])
        except subprocess.CalledProcessError:
            self._call_legacy_notarization_service(bundle_zip, keychain_profile)
        else:
            subprocess.check_call(
                ["xcrun", "notarytool", "submit", bundle_zip, "--keychain-profile", keychain_profile, "--wait"]
            )

    def _call_legacy_notarization_service(self, bundle_zip, keychain_profile):
        notarize_keychain_account = os.environ["NOTARIZE_KEYCHAIN_ACCOUNT"]
        submit_output = subprocess.check_output(
            [
                "xcrun",
                "altool",
                "--notarize-app",
                *["--primary-bundle-id", "com.clarabot.nano.zip"],
                *["-u", notarize_keychain_account, "-p", f"@keychain:{keychain_profile}"],
                *["--file", bundle_zip],
            ],
            text=True,
        )
        print(submit_output)
        for submit_line in reversed(submit_output.split("\n")):
            if submit_line.startswith("RequestUUID = "):
                submit_uuid = submit_line.partition(" = ")[-1].strip()
                break
        else:
            raise Exception(f"RequestUUID not found:\n{submit_output}")
        done = False
        while not done:
            print("Waiting for info...")
            time.sleep(60)
            print("Requesting info...")
            info_output = subprocess.check_output(
                [
                    "xcrun",
                    "altool",
                    *["--notarization-info", submit_uuid],
                    *["-u", notarize_keychain_account, "-p", f"@keychain:{keychain_profile}"],
                ],
                text=True,
            )
            print(info_output)
            for info_line in info_output.split("\n"):
                info_line = info_line.strip().lower()
                name, _, value = info_line.partition(":")
                if name == "status":
                    value = value.strip()
                    if value == "success":
                        done = True
                        break
                    elif value != "in progress":
                        raise Exception(f"Notarization result: {value}")

    def _assess_notarization(self):
        bundle_dir = os.path.realpath(self.bundleDir)
        subprocess.check_call(["spctl", "-vvv", "--assess", "--raw", "--verbose=10", "--type", "execute", bundle_dir])

    def _get_binary_type(self, p: Path) -> Optional[str]:
        try:
            m = MachO(p)
        except Exception:
            return
        if not m.headers:
            raise Exception(f"Hmm.. what's this? {m.headers} {p}")
        mts = set(mh.filetype for mh in m.headers)
        if len(mts) != 1:
            raise Exception(f"Hmm.. what's this? {mts} {p}")
        mt = list(mts)[0]
        if mt in (self.FILE_TYPE_EXE,):
            return self.FILE_TYPE_EXE
        elif mt in (self.FILE_TYPE_LIB, "bundle"):
            return self.FILE_TYPE_LIB
        raise Exception(f"Unexpected type: {mt} {p}")

    def _restructure_bundle(self):
        """
        We have to adhere to the rules Apple came up with regarding the application structure. There is no functional
        difference to the bundle before or after the restructuring. The necessity of it is because the code signing
        data and procedure is unreasonably rigid by design.
        Rules in a nutshell:
            - "MacOS" shall contain executables
            - "Frameworks" shall contain dynlib and so files
            - "Resources" shall not contain executables or frameworks
        It is literally impossible for a mixed binary-source python package to adhere to this ruleset, because one
        part of the codebase is considered resource while the other is not.. yet they need to be in the same place
        to function. I must mention that other binary software distributions such as a Java VM has their traditional
        UNIX layout where they "just work" and dissecting them for these arbitrary rules is not good either.
        This means we'll tree-split among the three directories while keeping symlinks to all files.
        This is not ideal.. but the requirements are not ideal either.
        NOTE: at some point I've fed up with this and put everything into custom folders...
        """
        frameworks_dir = Path(self.frameworksDir).resolve(strict=True)
        macos_dir = Path(self.binDir).resolve(strict=True)
        resources_dir = Path(self.resourcesDir).resolve(strict=True)
        resources_dir = resources_dir.parent / "data"
        resources_dir.mkdir()

        # The JVM breaks if we split by symlinks for some reason, so we keep the deps together.
        os.rename(macos_dir / "deps", macos_dir.parent / "deps")
        os.symlink("../deps", macos_dir / "deps")
        os.rename(macos_dir / "lib", macos_dir.parent / "lib")
        os.symlink("../lib", macos_dir / "lib")

        def _tree_split(rel):
            macos_path = macos_dir / rel
            t = self._get_binary_type(macos_path)
            if t == self.FILE_TYPE_EXE:
                return
            elif t == self.FILE_TYPE_LIB:
                target_dir = frameworks_dir
            else:
                target_dir = resources_dir
            target_path = target_dir / rel
            target_path.parent.mkdir(parents=True, exist_ok=True)
            os.rename(macos_path, target_path)
            link_path = "/".join(
                itertools.chain([".."] * len(rel.parts), target_path.relative_to(target_dir.parent).parts)
            )
            os.symlink(link_path, macos_path)

        for root, _, files in os.walk(macos_dir, followlinks=False):
            relative = Path(root).relative_to(macos_dir)
            for file in files:
                _tree_split(relative / file)

    def thinAllLibs(self, build_dir: str):
        archs = self._lipo_get_archs(sys.executable)
        if len(archs) != 1:
            raise Exception(f"What do I thin for? {archs}")
        target_arch = archs[0]
        saved = 0
        for root, _, files in os.walk(build_dir, followlinks=False):
            for file in files:
                file_path = Path(root, file)
                if self._get_binary_type(file_path):
                    s = self._lipo_thin(file_path, target_arch)
                    if s:
                        print("lipo save", s, file_path)
                        saved += s
        print("LIPO THIN SAVED", saved)

    def _lipo_get_archs(self, file) -> list[str]:
        lipo_info = subprocess.check_output(["lipo", str(file), "-info"], text=True)
        archs = []
        for arch in lipo_info.rpartition(":")[-1].split(" "):
            arch = arch.strip()
            if arch:
                archs.append(arch)
        archs.sort()
        return archs

    def _lipo_thin(self, file, target_arch: str) -> int:
        archs = self._lipo_get_archs(file)
        if target_arch not in archs:
            raise Exception(f"target arch {target_arch!r} missing from {file!r} found {archs}")
        if len(archs) == 1:
            return 0  # already thin
        pre_size = os.stat(file).st_size
        subprocess.check_call(["lipo", str(file), "-thin", target_arch, "-output", str(file)])
        return pre_size - os.stat(file).st_size
