#!/usr/bin/python3
"""
Library  handling for Fortran  dh_fortran_lib

Copyright (C) 2025 Alastair McKinstry <mckinstry@debian.org>
Released under the GPL-3 Gnu Public License.
"""

import os
import re
import click
from subprocess import check_output
from os.path import exists, basename, islink
import dhfortran.compilers as cmplrs
import dhfortran.debhelper as dh
import dhfortran.cli as cli
import magic
import fnmatch
from pathlib import Path

class Shlib:
    """
    Shared libs have a complex number of cases, so make a shlib object to
    # track all the info on a shlib
    """
    objs = {} # All known shlibs, key is orig 'libfoo'

    TYPE_UNKNOWN=0
    TYPE_NORMAL=1     # libfoo.so.2.1 etc
    TYPE_POSTFIX1=2   # libfoo-2.0.0.so
    TYPE_POSTFIX2=3   # libzzipmmapped-0.so.13.0.78
    TYPE_NOVER=4      # libfoo.so no soversion

    def __new__(cls , helper, orig_libfile: str, target_pkg, target_destdir):
        """ Creates or finds shlib object.
        Sets obj.name, other elements are set blank and filled by __init__"""
        # Insert abi into name
        flavor = helper.options.flavor
        abi = cmplrs.get_abi_vendor(flavor)
        
        p = Path(orig_libfile).resolve()
        name = p.name[:p.name.find('.so')]
        # reference. we may have multiple libraries with the same name, different
        # abis, and in different paths
        pn = f"{abi}:{target_destdir}:{name}"

        if pn in Shlib.objs:
            return Shlib.objs[pn]
        else:
            obj = object.__new__(cls)
            obj.flavor = flavor
            obj.abi = abi
            obj.name = name
            obj.orig_libfile = None
            obj.orig_libname = None
            obj.symlinks = []
            obj.libs_pkg = None
            obj.libdevel_pkg = None
            Shlib.objs[pn] = obj
            return obj

        
    def __init__(self, libfilehelper, orig_libfile: str, target_pkg, target_destdir):
        """
        """
        self.get_pkgs(libfilehelper, target_pkg)

        if self.libdevel_pkg == target_pkg:
            self.symlinks.append((orig_libfile,target_pkg,target_destdir))
            
        if islink(orig_libfile):
            self.symlinks.append((orig_libfile,target_pkg,target_destdir))
        else:
            self.orig_libfile = Path(orig_libfile).resolve()
            self.orig_libname = self.orig_libfile.name
            self.target_pkg = target_pkg
            self.target_destdir = target_destdir
            # Set kind,major,orig_soname,ext
            self.parse_shlib(orig_libfile)
        

    def get_pkgs(self, libfilehelper, target_pkg):
        """
        Each shlib belongs in 1 libs pkg, symlinks in 1 libdevel pkg
        """
        if len(libfilehelper.libs_pkgs) == 1:
            self.libs_pkg = libfilehelper.libs_pkgs[0]
        else:
            if target_pkg in libfilehelper.libs_pkgs:
                self.libs_pkg = target_pkg
        if len(libfilehelper.libdevel_pkgs) == 1:
            self.libdevel_pkg = libfilehelper.libdevel_pkgs[0]
        else:
            if target_pkg in libfilehelper.libdevel_pkgs:
                self.libdevel_pkg = target_pkg
        
    def parse_shlib(self, libfile: str):
        """ Helper. get soname from library. Passed real shlib
        set kind,major,orig_soname,ext
        """
            
        self.kind = Shlib.TYPE_UNKNOWN
        res = check_output(["objdump", "-p", libfile]).decode("utf-8")
        for line in res.split("\n"):
            lk = line.split()
            if len(lk) == 2 and lk[0] == "SONAME":
                soname=lk[1]
                self.orig_soname = soname
                if m:= re.search(r'-(\d+).so.(\d+)',soname):
                    # matches libfoo-0.so.13.1.2
                    self.kind=Shlib.TYPE_POSTFIX2
                    self.major = m.group(2)
                    self.ext = libfile[libfile.rfind(".so.")+3:]
                    return
                if m:= re.search(r'.so.(\d+(?:\.\d+)?)',soname):
                    # libpoints.so.1.2.3
                    self.kind = Shlib.TYPE_NORMAL
                    m2 = re.search(r'(\d+)', m.group(1))
                    self.major = m2.group(1)
                    self.ext = libfile[libfile.rfind(".so.")+3:]
                    return
                if m:= re.search(r'-(\d+\.\d+(?:\.\d+)?)',soname):
                    # libgctp-2.0.0.so
                    self.kind = Shlib.TYPE_POSTFIX1
                    sover = m.group(1)
                    self.major = sover[:sover.find('.')]
                    self.ext = ''
                    return
        # case libfoo.so, use this as soname.
        self.kind = Shlib.TYPE_NOVER
        self.major = None
        self.orig_soname = self.orig_libname
        self.ext = ''

        
    def set_new_soname_and_libname(self):
        """ Set the new soname for a file. In the case of no orig SO version,
        create a new soversion (self.major)
        Ditto for libname, libso
        """
        match self.kind:
            case Shlib.TYPE_NOVER: 
                # Ensure major is set
                self.major = '0d' # Default to a Debian 0d soversion
                if self.libs_pkg:
                    # is there a version postfix on the lib pkg name?
                    if m:=re.search(r'\d+$', self.libs_pkg):
                        self.major = m.group(0)
                self.ext = f".{self.major}"
                self.new_soname = f"{self.name}-{self.abi}.so.{self.major}"
                self.new_libname = f"{self.name}-{self.abi}.so{self.ext}"
                self.new_libso = f"{self.name}-{self.abi}.so"
            case Shlib.TYPE_POSTFIX1:
                # libgctp-2.0.0.so should become ligctp-gnu-2.0.0.so
                m= re.search(r'(.*)-(\d+(?:\.\d+)*)',self.orig_soname)
                self.new_soname = f"{m.group(1)}-{self.abi}-{m.group(2)}.so"
                self.new_libname = f"{m.group(1)}-{self.abi}-{m.group(2)}.so{self.ext}"
                self.new_libso = self.new_soname
            case Shlib.TYPE_POSTFIX2:
                # soname of the form libfoo-0.so.13  for libfoo-0.so.13.0.78
                m = re.search(r'(.*)-(\d+(?:\.\d+)*)',self.orig_soname)
                self.new_soname = f"{m.group(1)}-{self.abi}-{m.group(2)}.so.{self.major}"
                self.new_libname = f"{m.group(1)}-{self.abi}-{m.group(2)}.so{self.ext}"
                self.new_libso = f"{m.group(1)}-{self.abi}-{m.group(2)}.so"
            case _:
                assert self.major is not None
                prefix = self.orig_soname[: self.orig_soname.rfind(".so")]
                self.new_soname = f"{prefix}-{self.abi}.so.{self.major}"
                self.new_libname = f"{prefix}-{self.abi}.so{self.ext}"
                self.new_libso = f"{prefix}-{self.abi}.so"

    
    def process_shared_lib(self,helper, orig_libfile: str, target_pkg, target_destdir ):
        """ Called from process_file() multiple times for each lib or
            symlink we've been given
        """

        flibdir = f"/usr/lib/{cmplrs.multiarch}/"
        full_lib_path = f"debian/{self.libs_pkg}/{flibdir}"
        full_libdevel_path=f"debian/{self.libdevel_pkg}/{flibdir}"

        if not islink(orig_libfile):
            self.set_new_soname_and_libname()
            dh.install_dir(full_lib_path)
            
            helper.doit(["patchelf","--set-soname",self.new_soname,
                         "--output",f"{full_lib_path}/{self.new_libname}",
                         orig_libfile])
            if helper.needs_orig_libs():
                helper.doit(['cp',orig_libfile,f"{full_lib_path}"])
            dh.make_symlink(f"{full_lib_path}/{self.new_soname}",
                            f"{full_lib_path}/{self.new_libname}",                            
                            target_destdir)
                
        # If we know the new libname (processed lib already),
        # we can handle any symlinks we've been passed - add to dev pkg

        if 'new_libname' in self.__dict__:
            for (orig_libfile, link_target_pkg, link_target_dest) in self.symlinks:
                dh.install_dir(f"{full_libdevel_path}/fortran/{self.abi}")
                dh.make_symlink(f"{full_libdevel_path}/{self.new_libso}",
                                f"{full_libdevel_path}/{self.new_libname}",
                                target_destdir)
                dh.make_symlink(f"{full_libdevel_path}/fortran/{self.abi}/{self.name}.so",
                                f"{full_libdevel_path}/{self.new_libname}",
                                target_destdir)
                if helper.needs_orig_libs():
                    dh.make_symlink(f"{full_libdevel_path}/{self.name}.so",                                
                                    f"{full_libdevel_path}/{self.orig_libname}",
                                    target_destdir)
            self.symlinks = []

        

##
## Library-file -specific interface for debhelper
##
class LibFileHelper(dh.DhFortranHelper):

    def __init__(self, options):
        super().__init__(options, "fortran-lib", "dh_fortran_lib")

        self.remapped_libs = {}

    def needs_orig_libs(self) -> bool:
        """Do we need to ship the original libs, ie are any executables/libs linked
        against them ?"""
        if "DH_NO_ORIG_LIBS" in os.environ:
            return False
        return self.options.include_orig_libs

    
    def autogenerate_pkg_filelist(
        self, searchdirs: list[str], dest=None
    ) -> list[tuple[str, str, str]]:
        """For automatic mode, find  lib files to install"""

        worklist = []
        libs_pkg, libdevel_pkg = self.libs_pkgs[0], self.libdevel_pkgs[0]
        for d in searchdirs:
            if d is None:
                continue
            for root, dir, files in os.walk(d):
                if root.startswith("./debian"):
                    # Don't traverse down ./debian ( note './' to allow
                    # builddir=debian/tmp etc)
                    continue
                for item in fnmatch.filter(files, "*.a"):
                    f = f"{root}/{item}"
                    if "ar archive" in magic.from_file(f):
                        worklist.append((libdevel_pkg, f, dest))
                for item in fnmatch.filter(files, "*.so*"):
                    f = f"{root}/{item}"
                    if "ar archive" in magic.from_file(f):
                        print(f"DEBUG: adding ar archive {f} to {libs_pkg}")
                        worklist.append((libs_pkg, f, dest))
                        continue
                    if "shared object" in magic.from_file(f):
                        worklist.append((libs_pkg, f, dest))
                        continue
                    if item.endswith(".so"):
                        m=magic.from_file(f)
                        print(f"DEBUG {f=} {m=}")
                        if ("symbolic link" in m) or ("ar archive" in m):
                            print(f"DEBUG: adding {f}")
                            worklist.append((libdevel_pkg, f, dest))
        return worklist

    
    def can_work_automatically(self, *cmdline_files: list[str]) -> bool:
        """Should we automatically automagically detect lib files, or work on
        specified packages?
        Called by Debhelper.process_and_move_files()

        Run if we haven't run already, haven't been passed cmdline files,
        """
        if self.options.buildsystem != "fortran":
            cli.debug_print(
                "dh_fortran_lib : buildsystem not fortran; not running automatically"
            )
            return False
        if len(cmdline_files) > 0:
            cli.debug_print("dh_fortran_lib not automatic: files passed on cmdlline")
            return False
        for p in self.packages:
            if self.has_acted_on_package(p):
                cli.debug_print(
                    "dh_fortran_lib not automatic: has acted on pkgs already"
                )
                return False
        if len(self.config_file_pkgs) != 0:
            cli.verbose_print(
                "dh_fortran won't run automatically: user has given fortran-lib files"
            )
            return False
        if len(self.libdevel_pkgs) != 1 and len(self.libs_pkgs) != 1:
            cli.debug_print(
                "dh_fortran_lib: Need 1 lib, 1 libdevel pkg to run automatically"
            )
            return False
        cli.verbose_print(
            "No fortran-lib files found, 1 libdevel package, 1 libs pkg: "
            "may install libs automatically"
        )
        return True

    def compute_dest(self, libfile, target_pkg, target_dest=None) -> str:
        """What directory should a library be filed ?
        Should be called by base DebHelper() to move files
        _dest : directory (str)
        """
        abi = cmplrs.get_abi_vendor(self.options.flavor)
        flibdir = f"/usr/lib/{cmplrs.multiarch}/fortran/{abi}"
        if target_dest is None:
            return flibdir
        if target_dest.startswith("/"):
            return target_dest
        else:
            return f"{flibdir}/{target_dest}"

    def process_static_lib(self, orig_libfile, target_pkg, target_dest):
        libname = basename(orig_libfile)
        destfile = f"debian/{target_pkg}/{target_dest}/{libname}"
        if exists(destfile):
            if self.options.flavor == self.options.preferred:
                cli.verbose_print(f"{destfile} exists but will be overwritten"
                                  "(preferred)")
            else:
                cli.verbose_print(f"{destfile} exists and will not be overwritten")
                return

        dh.install_dir(f"debian/{target_pkg}/{target_dest}")
        self.doit(["cp", "--reflink=auto", "-a", orig_libfile, destfile])
        if (
            self.needs_orig_libs()
            and self.options.flavor == cmplrs.get_fc_default()
        ):
            destdir = f"debian/{target_pkg}/usr/lib/{cmplrs.multiarch}"
            dh.install_dir(destdir)
            self.doit(["cp", "--reflink=auto", "-a", orig_libfile, destdir])


    def process_file(
        self,
        pkg: str,
        libfile: str,
        target_pkg: str,
        target_destdir: str | None = None,
    ):
        """Called by Debhelper.process_and_move_files()"""
        cli.debug_print(
            f"process_file: name {libfile=}  {target_pkg=} {target_destdir=}"
        )
        destdir = self.compute_dest(libfile, target_pkg,  target_destdir)

        if libfile.endswith(".a"):
            self.process_static_lib(libfile, target_pkg, destdir)
            return
        if libfile.find(".so") == -1:
            cli.warning(f"file {libfile} is not an ELF file; ignoring")
            return
        # Handle shared lib
        s = Shlib(self, libfile, target_pkg, target_destdir)
        s.process_shared_lib(self, libfile, target_pkg, target_destdir)


@click.command(
    context_settings=dict(
        ignore_unknown_options=False,
    )
)
@click.option("--fc", help="Fortran compiler flavor (eg gfortran-14, flang-21)")
@click.option("--flavor", help="Fortran compiler flavor. DEPRECATED, use --fc instead")
@click.option(
    "--preferred", help="Preferred compiler flavor when installing, eg gfortran-15"
)
@click.option(
    "--include-orig-libs",
    help="Include original libs as well as moved versions",
    is_flag=True,
    default=True,
)
@click.argument("files", nargs=-1, type=click.UNPROCESSED)
@cli.debhelper_common_args
def dh_fortran_lib(files, *args, **kwargs):
    """
    *dh_fortran_lib* is a debhelper program that enables multiple compiler flavours
    of a Fortran library to be installed in parallel by mangling the library filename
    and SONAME.

    Fortran libraries compiled by different compilers are not expected to be
    ABI-compatible, and hence for multiple compilers to be supported simultaneously
    the libraries must be named differently,
    and shared libraries need to include the  compiler flavor in the SONAME.

    *dh_fortran_lib* makes this possible without changes being necessary to the upstream
    library code.

    It does this by renaming a library, for example:

       $(LIBDIR)/libfiat.so.1.2 => $(LIBDIR)/libfiat-flang.so.1.2
    =back

    Symlinks also get renamed:

       $(LIBDIR)/libfiat.so.1 => $(LIBDIR)/libfiat-flang.so.1

    A  compilation link is added per vendor /ABI :
    
       $(LIBDIR)/fortran/gfortran/libfiat.so -> $(LIBDIR)/libfiat-flang.so.1.2

    and the SONAME in the ELF file is changed:

       $ readelf -a $(LIBDIR)/libfiat.so.1.2 | grep SONAME
           0x000000000000000e (SONAME)             Library soname: [libfiat.so.1]
       $ readelf -a $(LIBDIR)/libfiat-flang.so.1.2 | grep SONAMEa
           0x000000000000000e (SONAME)             Library soname: [libfiat-flang.so.1]

    Note this is defined per ABI-vendor: if fc=gfortran-14, the link is named for 'gnu'.
    If the links are already defined, then the preferred flavor is examined. For each
    vendor, dh_fortran_lib has a preferred flavor: eg gfortran-14 or gfortran-15.
    If the flavor of the library provided is the preferred flavor, the library is
    replaced if it exists. The option --preferred can be used to override the default
    choice.

    For static libraries, we place them in a ABI-specific directory:

            $(LIBDIR)/fortran/gnu/libfiat.a

    The consequence of this is that any library that builds against I<libfiat> with
    appropriate search paths set will use *libfiat-gnu* instead. This enables parallel
    builds with multiple compiler flavors to be installed simultaneously.

    == USAGE

    The expected usage is that this will be called in debian/rules as:

        dh_fortran_lib --fc=$(FLAVOR) [--preferred=$(PREFERRED)] \
              $(BUILDDIR)/XXX/libfiat.so.1

    The files are installed in the tmpdir (usually debian/tmp) by default.

    When installing multiple flavors with the same ABI (eg gfortran-14, gfortran-15),
    you can use the --preferred option to state which library to install; in this case,
    if FLAVOR==PREFERRED, then
    this library is installed even if an existing library with the same ABI is present.
    """

    cli.debug_print(f"dh_fortran_lib called with files {files} kwargs {kwargs}")
    cli.validate_flavor(kwargs["flavor"])
    cli.validate_compiler(kwargs["fc"])
    fc, flavor = kwargs["fc"], kwargs["flavor"]
    if fc is not None:
        flavor = fc
    # Get defaults if not defined.
    flavor, arch = cmplrs.get_fc_flavor_arch(flavor)

    kwargs.update(
        {
            "flavor": flavor,
            "preferred": cmplrs.get_preferred(kwargs["preferred"], fc),
        }
    )

    d = LibFileHelper(dh.build_options(**kwargs))

    searchdirs = [d.options.builddir, d.options.tmpdir]
    d.process_and_move_files(searchdirs, *files)
    dh.install_dir("debian/.debhelper")
    with open("debian/.debhelper/remappped_fortran_libs", "wt+") as f:
        print(d.remapped_libs, file=f)


if __name__ == "__main__":
    import pytest

    pytest.main(["tests/lib.py"])
