#!/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, chmod, environ, unlink
from os.path import basename, exists, join, dirname, isdir, split, 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("/")
    l = link.split("/")
    # remove any dup '//'
    while '' in t:
        t.remove('')
    while ''  in l:
        l.remove('')
    while l and l[0] == t[0]:
        del l[0], t[0]
    return "/".join([".."] * (len(l) - 1) + t)


def move_file(fpath, dstdir):
    """Move file to dstdir. Works with symlinks (including relative ones)."""
    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": False,
        "remaining_packages": False,
        "sourcedir": None,
        "DOPACKAGES": [],
    }
    if options['arch'] == False:
        options['arch'] = None
    if type(options['package']) is 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 = package["package-type"]
            else:
                typ = "deb"
            pkg = {
                "substvars": {},
                "autoscripts": {},
                "arch": paragraph["architecture"],
                "type": typ,
            }
            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 package {binary_package} (missing {substvar} in Depends/Recommends)"
                        )
                        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(
                "debian/{}.debhelper.log".format(self.package), encoding="utf-8"
            ) as f:
                for line in f:
                    if line.strip() == self.command:
                        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"DEBUG 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 filedoublearray(self, cfgfile, searchdirs=None, ErrHandler=Exception):
        # Reads in the specified file, one line at a time. splits on words,
        # and returns an array of arrays of the contents.
        # 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)
        result = []
        with open(cfgfile, "r") as f:
            lines = f.readlines()
        for lineno, line in enumerate(lines, 1):
            if line.startswith("#"):
                continue  # fragile
            bits = line.strip().split()
            dest = bits[1] if len(bits) > 1 else ""
            matches = []
            try:
                x = self.find_files_double(searchdirs, dest, bits[0])
                result += x
            except Exception as ex:
                badline = f"Bad line {lineno} in {cfgfile}"
                cli.warning(badline)
        # TODO finish error parsing
        return result

    def find_files_double(self, searchdirs, dest, *files):
        """ Search for each file in searchdirs, return list of pairs (file, dest)
        Recurse into directories """
        result = []
        for f in files:
            if not f:
                continue
            matches = []
            for s in searchdirs:
                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_double([f"{s}/{p}"], subdest, "*")
                        matches += x
                    else:
                        matches.append([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"DEBUG doit({args},{kwargs})")
        if self.options.no_act:
            return
        result = 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 clone2(fname: str, suffix) -> str:
        """Clone a file, returning new filename

        typ in ('lib','cmake','pkgconf')
        either flavor or vendor must be set

        if rewrite is defined its a function:
        rewrite(oldname,newname,cb_dict)
        """
        # Rename cmake file. Should be named xxx.cmake
        # TODO Use pathlib
        pathbits = fname.split("/")
        basename = pathbits[-1]
        if len(pathbits) == 1:
            pth = "/".join(pathbits[:-1])
        else:
            pth = ""
        name, typ = ".".join(basename.split(".")[:-1]), basename.split(".")[-1]
        newname = pth + name + "-" + suffix + "." + typ
        if "NO_ACT" in os.environ:
            verbose_print(f"clone {fname} as {newname}")
        else:
            if rewrite_fn:
                rewrite_fn(fname, newname, cb_dict)
            else:
                with open(fname, "r") as f:
                    with open(newname, "w") as n:
                        n.write(f.read)
        return newname

    def pkgfile(self, package, suffix):
        """Incomplete clone of /usr/share/perl5/Debian/Debhelper/Dh_Lib.pm version"""
        hostarch, hostos = "FIXME", "FIXME"
        if "name" in self.options.__dict__:  # TODO, not populated
            name = self.options.name
            searchpath = [
                f"debian/{package}.{name}.{suffix}.{hostarch}",
                f"debian/{package}.{name}.{suffix}.{hostos}",
                f"debian/{package}.{name}.{suffix}",
                f"debian/{name}.{suffix}",
            ]
        else:
            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, filename):
        # 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 process_and_move_files(self, *files):
        for pkg in self.packages:
            if self.options.package is not None and pkg not in self.options.package:
                continue
            if self.is_udeb(pkg):
                continue
            if  pkg not in self.options.DOPACKAGES:
                continue
            if self.options.firstpackage and pkg != self.options.firstpackage:
                cli.verbose_print(
                    f"DEBUG: checking package FIRSTPACKAGE {self.options.firstpackage} only "
                )
                continue

            # 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.

            config_file = self.pkgfile(pkg, self.suffix)  # eg  "debian/pkg.fortran_mod"
            self.packages[pkg]["config_file"] = config_file
            search_dirs = [self.options.sourcedir, "." ]

            install_files =  self.find_files_double(search_dirs, "", *files)

            if config_file:
                install_files += self.filedoublearray(config_file, search_dirs)
                
            # TODO Skip excluded patterns in install files

            self.log_installed_files(pkg, [ i[0]  for i in  install_files])
            for f in install_files:
                dest = f[1] if len(f) > 1 else None
                target_dir = self.process_file(pkg, f[0], f"debian/{pkg}", dest)

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

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


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

    def __init__(self, options, suffix, tool_name):
        # TODO rework these
        self.command = {"fortran": "dh-fortran"}
        self.tool_name = tool_name
        self.suffix = suffix
        super().__init__(options, "fortran")


if __name__ == "__main__":
    import pytest

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