#!/usr/bin/python3
"""
Helper routines for dhfortran including inheriting from debhelper

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

import os
import dhfortran.cli as cli
from subprocess import check_output
import errno
import re
from glob import glob
from os import makedirs, environ, unlink
from os.path import basename, exists, join, dirname, isdir, islink
from sys import argv
from dhfortran import DEPENDS_SUBSTVARS, PKG_NAME_TPLS

###
### COPIED FROM dh-python
###


parse_dep = re.compile(
    r"""[,\s]*
    (?P<name>[^\s:]+)(?::any)?
    \s*
    \(?(?P<version>([>=<]{2,}|=)\s*[^\)]+)?\)?
    \s*
    (?:\[(?P<arch>[^\]]+)\])?
    """,
    re.VERBOSE,
).match


def relpath(target, link):
    """Return relative path.

    >>> relpath('/usr/share/python-foo/foo.py', '/usr/bin/foo', )
    '../share/python-foo/foo.py'
    """
    t = target.split("/")
    lk = link.split("/")
    # remove any dup '//'
    while "" in t:
        t.remove("")
    while "" in lk:
        lk.remove("")
    while lk and lk[0] == t[0]:
        del lk[0], t[0]
    return "/".join([".."] * (len(lk) - 1) + t)


# def move_file(fpath, dstdir):
#     """Move file to dstdir. Works with symlinks (including relative ones).
#     TODO: relative_symlink undefined; fn unused, delete? """
#     if isdir(fpath):
#         dname = split(fpath)[-1]
#         for fn in os.listdir(fpath):
#             move_file(join(fpath, fn), join(dstdir, dname))

#     if islink(fpath):
#         dstpath = join(dstdir, split(fpath)[-1])
#         relative_symlink(os.readlink(fpath), dstpath)
#         os.remove(fpath)
#     else:
#         os.rename(fpath, dstdir)


def build_options(**options):
    """Build an Options object from kw options"""
    default_options = {
        "arch": None,
        "package": [],
        "no_package": [],
        "match_packages": False,  # TODO Revist this decision
        "write_log": True,
        "remaining_packages": False,
        "sourcedir": None,
        "tmpdir": None,
        "DOPACKAGES": [],
    }
    if "arch" in options and options["arch"] is False:
        options["arch"] = None
    if "package" in options and isinstance(options["package"], str):
        options["package"] = [options["package"]]
    built_options = default_options
    built_options.update(options)
    return type("Options", (object,), built_options)


class DebHelper:
    """Reinvents the wheel / some dh functionality (Perl is ugly ;-P)"""

    def __init__(self, options, impl):
        self.options = options
        self.packages = {}
        self.build_depends = {}
        self.impl = impl
        skip_tpl = set()
        for name, tpls in PKG_NAME_TPLS.items():
            if name != impl:
                skip_tpl.update(tpls)
        skip_tpl = tuple(skip_tpl)
        substvar = DEPENDS_SUBSTVARS[impl]

        pkgs = options.package
        skip_pkgs = options.no_package

        try:
            with open("debian/control", "r", encoding="utf-8") as fp:
                paragraphs = [{}]
                field = None
                for lineno, line in enumerate(fp, 1):
                    if line.startswith("#"):
                        continue
                    if not line.strip():
                        if paragraphs[-1]:
                            paragraphs.append({})
                            field = None
                        continue
                    if line[0].isspace():  # Continuation
                        paragraphs[-1][field] += line.rstrip()
                        continue
                    if ":" not in line:
                        raise Exception(
                            "Unable to parse line %i in debian/control: %s"
                            % (lineno, line)
                        )
                    field, value = line.split(":", 1)
                    field = field.lower()
                    paragraphs[-1][field] = value.strip()
        except IOError:
            raise Exception("cannot find debian/control file")

        # Trailing new lines?
        if not paragraphs[-1]:
            paragraphs.pop()

        if len(paragraphs) < 2:
            raise Exception(
                "Unable to parse debian/control, found less than " "2 paragraphs"
            )

        self.source_name = paragraphs[0]["source"]

        build_depends = []
        for field in ("build-depends", "build-depends-indep", "build-depends-arch"):
            if field in paragraphs[0]:
                build_depends.append(paragraphs[0][field])
        build_depends = ", ".join(build_depends)
        for dep1 in build_depends.split(","):
            for dep2 in dep1.split("|"):
                details = parse_dep(dep2)
                if details:
                    details = details.groupdict()
                    if details["arch"]:
                        architectures = details["arch"].split()
                    else:
                        architectures = [None]
                    for arch in architectures:
                        self.build_depends.setdefault(details["name"], {})[arch] = (
                            details["version"]
                        )

        for paragraph_no, paragraph in enumerate(paragraphs[1:], 2):
            if "package" not in paragraph:
                raise Exception(
                    "Unable to parse debian/control, paragraph %i "
                    "missing Package field" % paragraph_no
                )
            binary_package = paragraph["package"]
            if skip_tpl and binary_package.startswith(skip_tpl):
                cli.verbose_print(f"skipping package: {binary_package}")
                continue
            # TODO. Expand this bit
            self.options.DOPACKAGES.append(binary_package)
            if pkgs and binary_package not in pkgs:
                continue
            if skip_pkgs and binary_package in skip_pkgs:
                continue
            if options.remaining_packages and self.has_acted_on_package(binary_package):
                continue
            if "package-type" in paragraph:
                typ = paragraph["package-type"]
            else:
                typ = "deb"
            if "section" in paragraph:
                section = paragraph["section"]
            else:
                section = None
            pkg = {
                "substvars": {},
                "autoscripts": {},
                "arch": paragraph["architecture"],
                "type": typ,
                "section": section,
            }
            if (
                options.arch is False
                and pkg["arch"] != "all"
                or options.arch is True
                and pkg["arch"] == "all"
            ):
                # TODO: check also if arch matches current architecture:
                continue

            if options.match_packages:
                if not binary_package.startswith(PKG_NAME_TPLS[impl]):
                    # package doesn't have common prefix (libfortran-)
                    # so lets check if Depends/Recommends contains the
                    # appropriate substvar
                    if substvar not in paragraph.get(
                        "depends", ""
                    ) and substvar not in paragraph.get("recommends", ""):
                        cli.verbose_print(
                            f"skipping {binary_package}: (missing {substvar})"
                        )
                        continue

            # Operate on binary_package
            self.packages[binary_package] = pkg

        fp.close()
        cli.verbose_print(
            f"source={self.source_name}, binary packages={list(self.packages.keys())}"
        )

    def has_acted_on_package(self, package):
        try:
            with open(f"debian/{package}.debhelper.log", encoding="utf-8") as f:
                for line in f:
                    if line.strip() == self.tool_name:
                        return True
        except IOError as e:
            if e.errno != errno.ENOENT:
                raise
        return False

    def make_symlink(self, src, dest, pkg=None):
        """make_symlink($dest, $src[, $pkg]) creates a symlink from  $dest -> $src.
        if $pkg is given, $dest will be created within it."""
        s = f"{pkg}/{src}" if pkg else src
        p = relpath(dest, src)
        if p == "":
            # src and dest are the same
            return
        cli.verbose_print(f"ln -s {s} {p}")
        if islink(s):  # So symlink doesn't fail on idempotent
            unlink(s)
        os.symlink(p, s)

    # Policy says that if the link is all within one toplevel
    # directory, it should be relative. If it's between
    # top level directories, leave it absolute.
    # if not self.options.no_act:
    #    relative_symlink(dest, src)

    def find_files(self, searchdirs, pkg, dest, *files):
        """Search for each file in searchdirs, return list of tuples (pkg,file, dest)
        Recurse into directories"""
        result = []
        for f in files:
            if not f:
                continue
            matches = []
            for s in searchdirs:
                if s is None:
                    continue
                nm = f[1:] if f.startswith("/") else f
                for p in glob(nm, root_dir=s):
                    if isdir(f"{s}/{p}"):
                        subdest = basename(p) if dest == "" else f"{dest}/{basename(p)}"
                        x = self.find_files(pkg, subdest, [f"{s}/{p}"], "*")
                        matches += x
                    else:
                        matches.append((pkg, f"{s}/{p}", dest))
            if matches:
                result += matches
            else:
                cli.warning(f"{self.tool_name}: missing file(s) {f}")
        return result

    def addsubstvar(self, package, name, value):
        """debhelper's addsubstvar"""
        self.packages[package]["substvars"].setdefault(name, []).append(value)

    def autoscript(self, package, when, template, args):
        """debhelper's autoscript"""
        self.packages[package]["autoscripts"].setdefault(when, {}).setdefault(
            template, []
        ).append(args)

    def complex_doit(self, *args, **kwargs):
        """Same as complex_doit() from Debian::Debhelper::Dh_Lib;"""
        cli.verbose_print(f"complex_doit({args},{kwargs})")
        if self.options.no_act:
            return
        # TODO

    def doit(self, *args, **kwargs):
        """Execute a command given as a list"""
        cli.verbose_print(f"doit({args},{kwargs})")
        if self.options.no_act:
            return
        check_output(*args)
        # TODO

    def is_udeb(self, package):
        return self.packages[package]["type"] == "udeb"

    def install_dir(self, dname: str):
        """mkdir -p dir in appropriate place"""
        cli.verbose_print(f"install_dir ({dname})")
        os.makedirs(dname, exist_ok=True)

    def save_autoscripts(self):
        for package, settings in self.packages.items():
            autoscripts = settings.get("autoscripts")
            if not autoscripts:
                continue

            for when, templates in autoscripts.items():
                fn = "debian/%s.%s.debhelper" % (package, when)
                if exists(fn):
                    with open(fn, "r", encoding="utf-8") as datafile:
                        data = datafile.read()
                else:
                    data = ""

                new_data = ""
                for tpl_name, args in templates.items():
                    for i in args:
                        # try local one first (useful while testing dh_python3)
                        fpath = join(
                            dirname(__file__), "..", "autoscripts/%s" % tpl_name
                        )
                        if not exists(fpath):
                            fpath = "/usr/share/debhelper/autoscripts/%s" % tpl_name
                        with open(fpath, "r", encoding="utf-8") as tplfile:
                            tpl = tplfile.read()
                        if self.options.compile_all and args:
                            # TODO: should args be checked to contain dir name?
                            tpl = tpl.replace("-p #PACKAGE#", "")
                        elif settings["arch"] == "all":
                            tpl = tpl.replace("#PACKAGE#", package)
                        else:
                            arch = environ["DEB_HOST_ARCH"]
                            tpl = tpl.replace("#PACKAGE#", "%s:%s" % (package, arch))
                        tpl = tpl.replace("#ARGS#", i)
                        if tpl not in data and tpl not in new_data:
                            new_data += "\n%s" % tpl
                if new_data:
                    data += "\n# Automatically added by {}".format(
                        basename(argv[0])
                    ) + "{}\n# End automatically added section\n".format(new_data)
                    with open(fn, "w", encoding="utf-8") as fp:
                        fp.write(data)

    def save_substvars(self):
        for package, settings in self.packages.items():
            substvars = settings.get("substvars")
            if not substvars:
                continue
            fn = "debian/%s.substvars" % package
            if exists(fn):
                with open(fn, "r", encoding="utf-8") as datafile:
                    data = datafile.read()
            else:
                data = ""
            for name, values in substvars.items():
                p = data.find("%s=" % name)
                if p > -1:  # parse the line and remove it from data
                    e = data[p:].find("\n")
                    line = data[p + len("%s=" % name) : p + e if e > -1 else None]
                    items = [i.strip() for i in line.split(",") if i]
                    if e > -1 and data[p + e :].strip():
                        data = "%s\n%s" % (data[:p], data[p + e :])
                    else:
                        data = data[:p]
                else:
                    items = []
                for j in values:
                    if j not in items:
                        items.append(j)
                if items:
                    if data:
                        data += "\n"
                    data += "%s=%s\n" % (name, ", ".join(items))
            data = data.replace("\n\n", "\n")
            if data:
                with open(fn, "w", encoding="utf-8") as fp:
                    fp.write(data)

    def pkgfile(self, package: str, suffix: str) -> str | None:
        """Incomplete clone of /usr/share/perl5/Debian/Debhelper/Dh_Lib.pm version"""
        hostarch, hostos = "FIXME", "FIXME"
        searchpath = [
            f"debian/{package}.{suffix}.{hostarch}",
            f"debian/{package}.{suffix}.{hostos}",
            f"debian/{package}.{suffix}",
            f"debian/{suffix}",
        ]
        for s in searchpath:
            if exists(s):
                return s
        return None

    def generated_file(self, package: str, filename: str):
        # Generated files are cleaned by dh_clean AND dh_prep
        # - Package can be set to "_source" to generate a file relevant
        #   for the source package (the meson build does this atm.).
        #   Files for "_source" are only cleaned by dh_clean.
        dirname = f"debian/.debhelper/generated/{package}"
        makedirs(dirname, exist_ok=True)
        pth = f"{dirname}/{filename}"
        return pth

    def log_installed_files(self, package, installed, exclude_patterns=[]):
        if self.options.no_act:
            return
        if installed == []:
            return
        log = self.generated_file(package, f"installed-by-{self.tool_name}")
        with open(log, "a") as f:
            for src in installed:
                print(src, file=f)

    def skip_package(self, pkg) -> bool:
        """Do we skip this package or process it ?"""
        if self.options.package is not None and pkg not in self.options.package:
            return True
        if self.is_udeb(pkg):
            return True
        if pkg not in self.options.DOPACKAGES:
            return True
        if self.options.firstpackage and pkg != self.options.firstpackage:
            cli.debug_print(
                f"checking package FIRSTPACKAGE {self.options.firstpackage} only "
            )
        return False

    def generate_pkg_filelist_from_cfgfile(
        self, searchdirs: list[str], pkg: str
    ) -> list[tuple[str, str, str]]:
        """Generate a list of files to work on
        @return list of tuples (package, filename, dest_dir)
        """

        def expand_variables(bits: list[str]) -> list[str]:
            """Helper fn. Do variable expansion on filepaths"""
            new_bits = []
            for fpath in bits:
                new_fpath = []
                for elm in fpath.split("/"):
                    if elm.startswith("$"):
                        # TODO
                        pass
                    new_fpath.append(elm)
                new_bits.append("/".join(new_fpath))
            return new_bits

        # Reads in the specified file, one line at a time. splits on words
        # If a value is passed in as the second parameter, then glob
        # expansion is done in the directory specified by the parameter ("." is
        # frequently a good choice).
        # TODO: In compat  13+, it will do variable expansion (after splitting the lines
        # into words)
        worklist = []
        cfgfile = self.packages[pkg]["config_file"]
        with open(cfgfile, "r") as f:
            lines = f.readlines()
        if lines[0].startswith("#!"):
            # Some config files are shell scripts that generate output, so ...
            lines = check_output(["./" + cfgfile]).decode("utf-8").split("\n")
        for lineno, line in enumerate(lines, 1):
            if line == "" or line.startswith("#"):
                continue  # fragile
            bits = line.strip().split()
            bits = expand_variables(bits)
            dest = bits[1] if len(bits) > 1 else ""
            try:
                worklist += self.find_files(searchdirs, pkg, dest, bits[0])
            except Exception:
                badline = f"Bad line {lineno} in {cfgfile}"
                cli.warning(badline)
        # TODO finish error parsing
        return worklist

    def process_and_move_files(self, search_dirs, *cmdline_files):
        """The bulk of the work.
        subclass provides methods can_work_automatically(),
           autogenerate_pkg_filelist(), process_file()
        """

        # Worklist: list of (pkg, file, dest) tuples
        worklist = []
        if self.can_work_automatically(*cmdline_files):
            worklist += self.autogenerate_pkg_filelist(search_dirs)
        else:
            for pkg in self.config_file_pkgs:
                if self.skip_package(pkg):
                    continue
                worklist += self.generate_pkg_filelist_from_cfgfile(search_dirs, pkg)
            if len(cmdline_files) > 0:
                # TODO . glob breakdown
                pkg = self.options.package
                if pkg is not None and not self.skip_package(pkg):
                    for f in cmdline_files:
                        worklist += self.find_files(search_dirs, pkg, "", f)

        # Look at the install files for all packages to handle
        # list-missing/fail-missing, but skip really installing for
        # packages that are not being acted on.

        # TODO Skip excluded patterns in install files

        for pkg, fil, dest in worklist:
            self.log_installed_files(pkg, [fil])
            self.process_file(pkg, fil, f"debian/{pkg}", dest)

        if not self.options.write_log:
            return
        for package, _ in self.packages.items():
            with open(f"debian/{package}.debhelper.log", "a", encoding="utf-8") as f:
                f.write(self.tool_name + "\n")

    def save(self):
        self.save_substvars()
        self.save_autoscripts()
        self.save_log()


class DhFortranHelper(DebHelper):
    """dhfortran-specific stuff"""

    def __init__(self, options, suffix: str, tool_name: str):
        # TODO rework these
        self.command = {"fortran": "dh-fortran"}
        self.tool_name = tool_name
        self.suffix = suffix
        super().__init__(options, "fortran")
        for pkg in self.packages:
            config_file = self.pkgfile(pkg, self.suffix)
            self.packages[pkg]["config_file"] = config_file
        self.libs_pkgs = [
            p for p in self.packages if self.packages[p]["section"] == "libs"
        ]
        self.libdevel_pkgs = [
            p for p in self.packages if self.packages[p]["section"] == "libdevel"
        ]
        self.config_file_pkgs = [
            p for p in self.packages if self.packages[p]["config_file"]
        ]


if __name__ == "__main__":
    import pytest

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