import os
import subprocess
import stat
from typing import List, Tuple, Dict, Optional, Set, Iterable


# In a MachO file, need to deal specially with links that use @executable_path,
# @loader_path, @rpath
#
# @executable_path - where ultimate calling executable is
# @loader_path - directory of current object
# @rpath - list of paths to check
# (earlier rpaths have higher priority, i believe)
#
# Resolving these variables (particularly @rpath) requires tracing through the
# sequence linked MachO files leading the the current file, to determine which
# directories are included in the current rpath.


class DarwinException(Exception):
    pass


def _isMachOFile(path: str) -> bool:
    """Determines whether the file is a Mach-O file."""
    if not os.path.isfile(path):
        return False
    p = subprocess.Popen(("file", path), stdout=subprocess.PIPE)
    if b"Mach-O" in p.stdout.readline():
        return True
    return False


class MachOReference:
    """Represents a linking reference from MachO file to another file."""

    def __init__(
        self, sourceFile: "DarwinFile", rawPath: str, resolvedPath: str
    ):
        """
        :param sourceFile: DarwinFile object for file in which the reference
        was found
        :param rawPath: The path that appears in the file
        (may include @rpath, etc.)
        :param resolvedPath: The path resolved to an explicit path to a file
        on system.
        """
        self.sourceFile: "DarwinFile" = sourceFile
        self.rawPath: str = rawPath
        self.resolvedPath: str = resolvedPath

        # isSystemFile is True if the target is a system file that will not be
        # included in package
        self.isSystemFile = False
        # True if the file is being copied into the package
        self.isCopied = False
        # reference to target DarwinFile (but only if file is copied into app)
        self.targetFile: Optional[DarwinFile] = None

    def setTargetFile(self, darwinFile: "DarwinFile"):
        self.targetFile = darwinFile
        self.isCopied = True


class DarwinFile:
    """
    A DarwinFile object tracks a file referenced in the application, and record
    where it was ultimately moved to in the application bundle. Should also
    save a copy of the DarwinFile object, if any!, created for each referenced
    library.
    """

    def __init__(
        self,
        originalFilePath: str,
        referencingFile: Optional["DarwinFile"] = None,
    ):
        """
        :param originalFilePath: The original path of the DarwinFile (before
        copying into app)
        :param referencingFile: DarwinFile object representing the referencing
        source file
        """
        self.originalFilePath = os.path.realpath(originalFilePath)
        # path to file in build directory
        self._buildPath: Optional[str] = None
        self.commands: List[MachOCommand] = []
        self.loadCommands: List[MachOLoadCommand] = []
        self.rpathCommands: List[MachORPathCommand] = []

        # note: if file gets referenced twice (or more), it will only be the
        # first reference that gets recorded.
        self.referencingFile: Optional[DarwinFile] = None
        self.libraryPathResolution: Dict[str, str] = {}
        self._rpath: Optional[List[str]] = None

        # dictionary of MachOReference objects, by their resolved paths
        self.machOReferenceDict: Dict[str, MachOReference] = {}
        self.isMachO = False

        if not _isMachOFile(path=self.originalFilePath):
            return

        # if this is a MachO file, extract linking information from it
        self.isMachO = True
        self.commands = MachOCommand._getMachOCommands(
            forFileAtPath=self.originalFilePath
        )
        self.loadCommands = [
            c for c in self.commands if isinstance(c, MachOLoadCommand)
        ]
        self.rpathCommands = [
            c for c in self.commands if isinstance(c, MachORPathCommand)
        ]
        self.referencingFile = referencingFile

        self.getRPath()
        self.resolveLibraryPaths()

        for rawPath, resolvedPath in self.libraryPathResolution.items():
            if resolvedPath in self.machOReferenceDict:
                raise DarwinException(
                    "Dynamic libraries resolved to the same file?"
                )
            self.machOReferenceDict[resolvedPath] = MachOReference(
                sourceFile=self, rawPath=rawPath, resolvedPath=resolvedPath
            )
        return

    def __str__(self):
        l = []
        # l.append("RPath Commands: {}".format(self.rpathCommands))
        # l.append("Load commands: {}".format(self.loadCommands))
        l.append(f"Mach-O File: {self.originalFilePath}")
        l.append("Resolved rpath:")
        for rp in self.getRPath():
            l.append(f"   {rp}")
        l.append("Loaded libraries:")
        for rp in self.libraryPathResolution:
            l.append("   {} -> {}".format(rp, self.libraryPathResolution[rp]))
        return "\n".join(l)

    def setBuildPath(self, path: str):
        self._buildPath = path

    def getBuildPath(self) -> Optional[str]:
        return self._buildPath

    @staticmethod
    def isExecutablePath(path: str) -> bool:
        return path.startswith("@executable_path")

    @staticmethod
    def isLoaderPath(path: str) -> bool:
        return path.startswith("@loader_path")

    @staticmethod
    def isRPath(path: str) -> bool:
        return path.startswith("@rpath")

    def sourceDir(self) -> str:
        return os.path.dirname(self.originalFilePath)

    def resolveLoader(self, path: str) -> Optional[str]:
        """Resolve a path that includes @loader_path.
        @loader_path represents the directory in which the DarwinFile is
        located."""
        if self.isLoaderPath(path=path):
            return path.replace("@loader_path", self.sourceDir(), 1)
        raise DarwinException(f"resolveLoader() called on bad path: {path}")

    def resolveExecutable(self, path: str) -> str:
        """
        @executable_path should resolve to the directory where the original
        executable was located. By default, we set that to the directory of the
        library, so it would resolve in the same was as if linked from an
        executable in the same directory.
        """
        # consider making this resolve to the directory of the target script
        # instead?
        if self.isExecutablePath(path=path):
            return path.replace("@executable_path", self.sourceDir(), 1)
        raise DarwinException(
            f"resolveExecutable() called on bad path: {path}"
        )

    def resolveRPath(self, path: str) -> str:
        for rp in self.getRPath():
            testPath = os.path.abspath(path.replace("@rpath", rp, 1))
            if _isMachOFile(testPath):
                return testPath
        raise DarwinException(f"resolveRPath() failed to resolve path: {path}")

    def getRPath(self) -> List[str]:
        """
        Returns the rpath in effect for this file.  Determined by rpath
        commands in this file and (recursively) the chain of files that
        referenced this file.
        """
        if self._rpath is not None:
            return self._rpath
        rawPaths = [c.rPath for c in self.rpathCommands]
        rpath = []
        for rp in rawPaths:
            if os.path.isabs(rp):
                rpath.append(rp)
            elif self.isLoaderPath(rp):
                rpath.append(self.resolveLoader(rp))
            elif self.isExecutablePath(rp):
                rpath.append(self.resolveExecutable(rp))

        rpath = [os.path.abspath(rp) for rp in rpath]
        rpath = [rp for rp in rpath if os.path.exists(rp)]

        if self.referencingFile is not None:
            rpath = self.referencingFile.getRPath() + rpath
        self._rpath = rpath
        return self._rpath

    def resolvePath(self, path) -> str:
        """
        Resolves any @executable_path, @loader_path, and @rpath references
        in a path.
        """
        if self.isLoaderPath(path):  # replace @loader_path
            return self.resolveLoader(path)
        if self.isExecutablePath(path):  # replace @executable_path
            return self.resolveExecutable(path)
        if self.isRPath(path):  # replace @rpath
            return self.resolveRPath(path)
        if os.path.isabs(path):  # just use the path, if it is absolute
            return path
        testPath = os.path.abspath(os.path.join(self.sourceDir(), path))
        if _isMachOFile(path=testPath):
            return testPath
        raise DarwinException(f"Could not resolve path: {path}")

    def resolveLibraryPaths(self):
        for lc in self.loadCommands:
            rawPath = lc.loadPath
            resolvedPath = self.resolvePath(path=rawPath)
            self.libraryPathResolution[rawPath] = resolvedPath

    def getDependentFilePaths(self) -> List[str]:
        dependents: List[str] = []
        for ref in self.machOReferenceDict.values():
            dependents.append(ref.resolvedPath)
        return dependents

    def getMachOReference(self, resolvedPath: str) -> MachOReference:
        if resolvedPath not in self.machOReferenceDict:
            raise DarwinException(
                f"Path {resolvedPath} is not a path referenced from DarwinFile"
            )
        return self.machOReferenceDict[resolvedPath]

    def getMachOReferences(self) -> Iterable[Tuple[str, MachOReference]]:
        return self.machOReferenceDict.items()

    def setCopyDestination(self, destinationPath: str):
        """
        Tell the Mach-O file its relative position (compared to executable)
        in the bundled package.
        """
        self._buildPath = destinationPath


class MachOCommand:
    """Represents a load command in a MachO file."""

    def __init__(self, lines: List[str]):
        self.lines = lines

    def __repr__(self):
        return "<MachOCommand>"

    @staticmethod
    def _getMachOCommands(forFileAtPath: str) -> List["MachOCommand"]:
        """
        Returns a list of load commands in the specified file, using otool.
        """
        shellCommand = f'otool -l "{forFileAtPath}"'
        commands: List[MachOCommand] = []
        currentCommandLines = None

        # split the output into separate load commands
        for line in os.popen(shellCommand):
            line = line.strip()
            if line[:12] == "Load command":
                if currentCommandLines is not None:
                    commands.append(
                        MachOCommand.parseLines(lines=currentCommandLines)
                    )
                currentCommandLines = []
            if currentCommandLines is not None:
                currentCommandLines.append(line)
        if currentCommandLines is not None:
            commands.append(currentCommandLines)
        return commands

    @staticmethod
    def parseLines(lines: List[str]) -> "MachOCommand":
        if len(lines) < 2:
            return MachOCommand(lines=lines)
        commandLinePieces = lines[1].split(" ")
        if commandLinePieces[0] != "cmd":
            return MachOCommand(lines=lines)
        if commandLinePieces[1] == "LC_LOAD_DYLIB":
            return MachOLoadCommand(lines=lines)
        if commandLinePieces[1] == "LC_RPATH":
            return MachORPathCommand(lines=lines)
        return MachOCommand(lines=lines)


class MachOLoadCommand(MachOCommand):
    def __init__(self, lines: List[str]):
        super().__init__(lines=lines)
        self.loadPath = None
        if len(self.lines) < 4:
            return
        pathline = self.lines[3]
        pathline = pathline.strip()
        if not pathline.startswith("name "):
            return
        pathline = pathline[4:].strip()
        pathline = pathline.split("(offset")[0].strip()
        self.loadPath = pathline
        return

    def getPath(self):
        return self.loadPath

    def __repr__(self):
        return f"<LoadCommand path={self.loadPath!r}>"


class MachORPathCommand(MachOCommand):
    def __init__(self, lines: List[str]):
        super().__init__(lines=lines)
        self.rPath = None
        if len(self.lines) < 4:
            return
        pathline = self.lines[3]
        pathline = pathline.strip()
        if not pathline.startswith("path "):
            return
        pathline = pathline[4:].strip()
        pathline = pathline.split("(offset")[0].strip()
        self.rPath = pathline
        return

    def __repr__(self):
        return f"<RPath path={self.rPath!r}>"


def _printFile(
    darwinFile: DarwinFile,
    seenFiles: Set[DarwinFile],
    level: int,
    noRecurse=False,
):
    """
    Utility function to prints details about a DarwinFile and (optionally)
    recursively any other DarwinFiles that it references.
    """
    print("{}{}".format(level * "|  ", darwinFile.originalFilePath), end="")
    print(" (already seen)" if noRecurse else "")
    if noRecurse:
        return
    for ref in darwinFile.machOReferenceDict.values():
        if not ref.isCopied:
            continue
        mf = ref.targetFile
        _printFile(
            mf,
            seenFiles=seenFiles,
            level=level + 1,
            noRecurse=(mf in seenFiles),
        )
        seenFiles.add(mf)
    return


def printMachOFiles(fileList: List[DarwinFile]):
    seenFiles = set()
    for mf in fileList:
        if mf not in seenFiles:
            seenFiles.add(mf)
            _printFile(mf, seenFiles=seenFiles, level=0)


def changeLoadReference(
    fileName: str, oldReference: str, newReference: str, VERBOSE: bool = True
):
    """
    Utility function that uses intall_name_tool to change oldReference to
    newReference in the machO file specified by fileName.
    """
    if VERBOSE:
        print("Redirecting load reference for ", end="")
        print(f"<{fileName}> {oldReference} -> {newReference}")
    original = os.stat(fileName).st_mode
    newMode = original | stat.S_IWUSR
    os.chmod(fileName, newMode)
    subprocess.call(
        ("install_name_tool", "-change", oldReference, newReference, fileName)
    )
    os.chmod(fileName, original)


class DarwinFileTracker:
    """Object to track the DarwinFiles that have been added during a freeze."""

    def __init__(self):
        # a list of DarwinFile objects for files being copied into project
        self._targetFileList: List[DarwinFile] = []

        # a mapping of (build directory) target paths to DarwinFile objects
        self._targetFileDict: Dict[str, DarwinFile] = {}

        self._sourceFileDict: Dict[str, DarwinFile] = {}

        # a mapping of (source location) paths to the MacOReferences to them
        self._referenceCache: Dict[str, MachOReference] = {}

    def __iter__(self) -> Iterable[DarwinFile]:
        return iter(self._targetFileList)

    def pathIsAlreadyCopiedTo(self, targetPath: str) -> bool:
        """Check if the given targetPath has already has a file copied to it."""
        if targetPath in self._targetFileDict:
            return True
        return False

    def getDarwinFile(self, sourcePath: str, targetPath: str) -> DarwinFile:
        """
        Gets the DarwinFile for file copied from sourcePath to targetPath.
        If either (i) nothing, or (ii) a different file has been copied to
        targetPath, raises a DarwinException.
        """

        # check that the file has been copied to
        if targetPath not in self._targetFileDict:
            raise DarwinException(
                f"File {targetPath!r} already copied to, "
                "but no DarwinFile object found for it."
            )

        # check that the target file came from the specified source
        targetDarwinFile: DarwinFile = self._targetFileDict[targetPath]
        realSource = os.path.realpath(sourcePath)
        targetRealSource = os.path.realpath(targetDarwinFile.originalFilePath)
        if realSource != targetRealSource:
            raise DarwinException(
                f"Attempting to copy two files to {targetPath!r}\n"
                f"source 1: {targetDarwinFile.originalFilePath!r} "
                f"(real: {targetRealSource!r})\n"
                f"source 2: {sourcePath!r} (real: {realSource!r})\n"
                "(This may be caused by including modules in the zip file "
                "that rely on binary libraries with the same name.)"
            )
        return targetDarwinFile

    def recordCopiedFile(self, targetPath: str, darwinFile: DarwinFile):
        """
        Record that a DarwinFile is being copied to a given path.  If the
        same file has already been copied to that path, do nothing. If a
        different file has been copied to that bath, raise a DarwinException.
        """
        if self.pathIsAlreadyCopiedTo(targetPath=targetPath):
            raise DarwinException(
                "addFile() called with targetPath already copied to "
                f"(targetPath={targetPath!r})"
            )

        self._targetFileList.append(darwinFile)
        self._targetFileDict[targetPath] = darwinFile
        self._sourceFileDict[darwinFile.originalFilePath] = darwinFile

    def cacheReferenceTo(self, path: str, machOReference: MachOReference):
        self._referenceCache[path] = machOReference

    def getCachedReferenceTo(self, path: str) -> Optional[MachOReference]:
        if path in self._referenceCache:
            return self._referenceCache[path]
        return None

    def finalizeReferences(self):
        """
        Goes through the stored list of target files, and updates references.
        Normally the references may not be updated automatically, if _CopyFile
        is called without copyDependentFiles=True.
        """

        for df in self._targetFileList:  # DarwinFile
            for (path, ref) in df.getMachOReferences():
                # path and corresponding MachOReference to path
                if not ref.isCopied:
                    # if reference not already marked as copied, check if it
                    # points to a file that is being copied and, if so, use
                    # that to complete the reference
                    realTargetPath = os.path.realpath(path)
                    if realTargetPath in self._sourceFileDict:
                        ref.setTargetFile(self._sourceFileDict[realTargetPath])
