#!/usr/bin/python3
# SPDX-FileCopyrightText: 2026-present Mia Herkt <mia@0x0.st>
# SPDX-License-Identifier: Apache-2.0
"""Dynamic spec generator for Meson.

This uses Meson’s introspection to generate most of a spec file as dynamic spec
parts (RPM 4.19). It is intended to be run in the configured build directory
and prints the .specpart contents to stdout.

The intended use is via the RPM 4.20 declarative buildsystem feature.
With this, the spec file can be reduced to just the preamble — everything else
is inferred from Meson’s build output.

Upstream: https://git.0x0.st/mia/opensuse-rpm-magic
"""

import json
import math
import operator
import os
import re
import sys
from collections import defaultdict
from dataclasses import dataclass
from functools import cache, cached_property, partialmethod, reduce
from pathlib import Path
from subprocess import CalledProcessError, run


def log(*args, indent=0, prefix=None, **kwargs) -> None:
    """Write a message to standard error.

    Keyword arguments:
    indent -- Indent with this many spaces
    prefix -- Prefix for the log message
    """
    ind = indent * ' '
    pre = [*ind, prefix] if prefix else ind
    msg = [f'\n{pre}'.join(str(x).split('\n')) for x in args]
    print(*((*pre, ' ', *msg) if len(pre) else msg), sep='', file=sys.stderr, **kwargs)


def warn(*args, **kwargs) -> None:
    """Log a warning to standard error.

    Keyword arguments:
    indent -- Indent with this many spaces
    """
    log(*args, prefix='⚠️', **kwargs)


def sizefmt(size: int) -> str:
    """Return a string of a file size in bytes with SI prefixes."""
    if size == 0:
        return '0 B'
    unit = ('B', 'KiB', 'MiB', 'GiB', 'TiB')
    exp = min(len(unit) - 1, int(math.log(size, 1024)))
    fmt = f'{size / (1024 ** exp):.1f}' if exp > 0 else size
    return f'{fmt} {unit[exp]}'


class RPM:
    """Querying functions for spec files and the RPM database."""

    @classmethod
    def source_dir(cls):
        """Return a pathlib.Path object of the RPM source directory."""
        return Path(os.environ['RPM_SOURCE_DIR'])

    @classmethod
    @cache
    def cmd(cls, prog, check=True, **kwargs) -> str | None:
        """Run the command `prog` with kwargs turned into command line options.

        Returns:
        The command’s standard output if successful, None on error.

        Keyword arguments:
        check -- Whether to throw subprocess.CalledProcessError on error
        """
        args = reduce(
            operator.iconcat,
            [
                (('-' if len(k) == 1 else '--') + k, *([v] if v else []))
                for k, v in kwargs.items()
            ]
        )
        res = run([prog, *args], capture_output=True, check=check)
        if res.returncode == 0:
            return res.stdout.decode().strip()

        return None

    @classmethod
    @cache
    def spec_path(cls) -> Path:
        """Return a pathlib.Path object of the spec file path.

        Throw a RuntimeError if it could not be found.
        """
        glob = list(cls.source_dir().glob('*.spec'))
        if len(glob) == 1:
            return glob[0]

        sp = cls.source_dir() / os.environ['RPM_PACKAGE_NAME'] + '.spec'
        if sp.exists():
            return sp

    @classmethod
    def query_spec(cls, tag: str) -> str:
        """Query the spec file for a tag and return the result as a string."""
        return cls.cmd(
            'rpmspec', query=cls.spec_path(), queryformat=f'%{{{tag}}}')

    @classmethod
    def query_rpm(cls, **kwargs) -> str | None:
        """Query the RPM database and return a string or None if no result."""
        return cls.cmd('rpm', '-q', **kwargs)

    name = partialmethod(query_spec, 'NAME')
    description = partialmethod(query_spec, 'DESCRIPTION')
    summary = partialmethod(query_spec, 'SUMMARY')

    @classmethod
    @cache
    def whatprovides(cls, cap: Path) -> str:
        """Query what package provides a file."""
        res = cls.query_rpm(whatprovides=cap, queryformat=r'%{NAME}\n')
        assert '\n' not in res and len(res) > 0
        return res

    @classmethod
    @cache
    def whatrequires(cls, capability: str) -> list:
        """Query what packages require a capability."""
        res = cls.query_rpm(
            whatrequires=capability, queryformat=r'%{NAME}\n', check=False)
        if res:
            r = res.split('\n')
            assert len(r)
            return r

        return []

    @classmethod
    @cache
    def rpm_eval(cls, expr: str) -> str:
        """Evaluate an RPM expression and return the result."""
        return cls.cmd('rpm', eval=expr)

    @classmethod
    def datadir(cls) -> Path:
        """Return the %_datadir variable as pathlib.Path object."""
        return Path(cls.rpm_eval(r'%?_datadir')
                    or cls.rpm_eval(r'%{?_defaultdatadir}'))

    @classmethod
    def licensedir(cls) -> Path:
        return cls.datadir() / 'licenses'

    @classmethod
    def docdir(cls) -> Path:
        """Return the %_docdir variable as pathlib.Path object."""
        return Path(cls.rpm_eval(r'%{?_docdir}')
                    or cls.rpm_eval(r'%{?_defaultdocdir}'))

    @classmethod
    def includedir(cls) -> Path:
        """Return the %_docdir variable as pathlib.Path object."""
        return Path(cls.rpm_eval(r'%{?_includedir}'))

    @classmethod
    def mandir(cls) -> Path:
        """Return the %_mandir variable as pathlib.Path object."""
        return Path(cls.rpm_eval(r'%{?_mandir}')
                    or cls.rpm_eval(r'%{?_defaultmandir}'))

    @classmethod
    def localedir(cls) -> Path:
        """Return the locale directory as pathlib.Path object."""
        return cls.datadir() / 'locale'

    ext_man = partialmethod(rpm_eval, r'%{?ext_man}')

    @classmethod
    def buildroot(cls) -> Path:
        """Return the RPM build root as pathlib.Path object."""
        return Path(os.environ['RPM_BUILD_ROOT'])


@dataclass
class Dependency:
    """A package dependency."""

    name: str
    method: str | None = None
    link_args: tuple | None = None

    def __post_init__(self):
        """Post-initialize the Dependency object."""
        def filter_linkarg(arg: str) -> bool:
            for s in ('/', '-L', '-l'):
                if arg.startswith(s):
                    return True
            return False
        if self.link_args:
            self.link_args = tuple(filter(filter_linkarg, self.link_args))

    @cached_property
    def rpmdep(self) -> str | None:
        """The name of the dependency as used in e.g. Requires."""

        def parse_linkargs() -> Path:
            search = Path(RPM.rpm_eval(r'%{_libdir}'))
            for arg in self.link_args:
                if arg.startswith('-L'):
                    search = Path(arg[2:])
                elif arg.startswith('-l'):
                    for suf in ('.so', '.a'):
                        lib = search / f'lib{arg[2:]}{suf}'
                        if lib.exists():
                            yield arg, lib
                            break
                        try:
                            lib = f'lib{arg[2:]}-devel'
                            if RPM.whatprovides(lib):
                                yield arg, lib
                            break
                        except (CalledProcessError, AssertionError): \
                                # noqa: B030
                            pass
                    else:
                        raise RuntimeError(
                            f'Could not figure out link_arg {arg} while '
                            ' trying to resolve devel package for dependency'
                            f'{self.name} (method: {self.method})'
                        )
                elif arg.startswith('/'):
                    assert (p := Path(arg)).exists()
                    yield p

        def get_packages_from_linkargs() -> str:
            for arg, f in parse_linkargs():
                wp = RPM.whatprovides(f)
                if wp.endswith('-devel'):
                    log(f'{arg} -> {wp}', indent=3, prefix='🔍')
                    yield wp
                else:
                    for res in RPM.whatrequires(wp):
                        if res.endswith('-devel'):
                            log(f'{arg} -> {res}', indent=3, prefix='🔍')
                            yield res

        match self.method:
            case 'pkgconfig' | 'cmake':
                return f'{self.method}({self.name})'
            case 'system' | 'config-tool' | 'library' | 'builtin':
                if len(self.link_args):
                    warn(
                        f'Dependency "{self.name}" (method "{self.method}")'
                        ' does not map cleanly to RPM Requires.\n'
                        'Attempting to deduce packages from linker arguments.'
                    )
                    pkgs = sorted(set(get_packages_from_linkargs()))
                    return ' '.join(pkgs) if len(pkgs) > 0 else None
            case None:
                return self.name
            case _:
                raise RuntimeError(
                    f'Unhandled dependency {self.name}, method {self.method}')


class Depends:
    """RPM dependencies."""
    def __init__(self):
        self._requires = set()
        self._provides = set()
        self._supplements = set()
        self._enhances = set()

    def _accept_requires(self, dep: Dependency) -> bool:
        return dep.method is None

    def __add_dep(self, which: str, dep: Dependency) -> None:
        """Add a Dependency to this package."""
        assert isinstance(dep, Dependency)
        if self._accept_requires(dep) and dep.rpmdep:
            getattr(self, which).add(dep.rpmdep)

    add_requires = partialmethod(__add_dep, '_requires')
    add_provides = partialmethod(__add_dep, '_provides')
    add_supplements = partialmethod(__add_dep, '_supplements')
    add_enhances = partialmethod(__add_dep, '_enhances')

    @property
    def requires(self) -> set[Dependency]:
        """Required capabilities."""
        return self._requires

    @property
    def provides(self) -> set[Dependency]:
        """Provided capabilities."""
        return self._provides

    @property
    def supplements(self) -> set[Dependency]:
        """Supplemented capabilities."""
        return self._supplements

    @property
    def enhances(self) -> set[Dependency]:
        """Enhanced capabilities."""
        return self._enhances


class File(Depends):
    """A file in a package."""

    _icon = '📄'

    def __init__(self, buildpath: Path, path: Path):
        """Return a File object."""
        self._buildpath = buildpath
        self._path = path
        self._macros = set()
        super().__init__()

    def __str__(self):
        """Return a string that is usable in the RPM spec file list."""
        return str(self.path)

    @property
    def displaystr(self):
        """A string for use in human-readable file listings."""
        return f'{self._icon}  {self.path.name}'

    @property
    def path(self):
        """Adjusted installed file path as a pathlib.Path object."""
        return self._path

    @property
    def buildpath(self):
        """File path in the build directory as a pathlib.Path object."""
        return self._buildpath

    @property
    def oldpath(self) -> Path:
        """Installed file path prior to adjustment as a pathlib.Path object."""
        return self._path if self._path != self.path else None

    @property
    def dirs(self) -> set[Path]:
        """List of directories that need to be owned for this file."""
        return set()

    @cached_property
    def size(self):
        """File size in bytes."""
        return self.buildpath.stat().st_size

    @property
    def macros(self) -> set:
        """A set of macros for this file."""
        return self._macros

    @cached_property
    def isarch(self) -> bool:
        """Whether this file might be architecture-dependent."""
        r = run(['readelf', '-h', self.buildpath], capture_output=True)
        return r.returncode == 0

    @classmethod
    def istype(cls, ins) -> bool:
        """Return whether this is the appropriate type for the given InstallFile."""
        raise NotImplementedError

    @property
    def isunwanted(self) -> bool:
        """Whether this file is unwanted and should be removed."""
        return False


class UninstalledFile(File):
    """A file that has an install rule but was not installed."""

    _icon = '❓'

    def __str__(self) -> str:
        return ''

    @classmethod
    def istype(cls, ins) -> bool:
        return False


class DevelFile(File):
    """A development file."""

    _icon = '🛠️📄'

    def _accept_requires(self, dep: Dependency) -> bool:
        return True

    @property
    def dirs(self) -> list[Path]:
        rel = RPM.includedir()
        if self.path.is_relative_to(rel):
            for p in self.path.relative_to(rel).parents:
                if len(p.parts) > 0:
                    yield rel / p

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.tag == 'devel' and (
            ins.category == 'data'
            or ins.category == 'headers'
        )


class SharedLibFile(File):
    """A shared library in a package."""

    _icon = '📚'

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.tag == 'runtime' \
            and ins.target_type == 'shared library' \
            and '.so.' in ins.path.name

    @cached_property
    def soname(self) -> tuple[str, str]:
        """The SONAME and SOVERSION of this shared library."""
        p = self.buildpath
        r = run(['readelf', '-W', '-d', p], capture_output=True, check=True)
        m = re.search(
            r'Library soname: \[(?P<lib>\w+)\.so\.(?P<ver>[0-9\.]+)\]',
            r.stdout.decode(),
        )

        assert m, \
            f'Library has no SONAME: {self.path}'

        d = m.groupdict()

        return d['lib'], d['ver']


class DevelLibFile(DevelFile):
    """A devel library in a package."""

    _icon = '🛠️📚'

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.tag == 'runtime' \
            and ins.target_type == 'shared library' \
            and '.so.' not in ins.path.name


class ExecutableFile(File):
    """An executable file in a package."""

    _icon = '⚙️'

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.tag == 'runtime' and (
            ins.target_type == 'executable'
            or os.access(ins.buildpath, os.X_OK)
        )


class LicenseFile(File):
    """A license file in a package."""

    _icon = '©️'

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.category == 'depmf'

    @property
    def dirs(self) -> list[Path]:
        rel = RPM.licensedir()
        for p in self.path.relative_to(rel).parents:
            if len(p.parts) > 0:
                yield rel / p

    def __str__(self):
        """Return the RPM file listing string prefixed with %license."""
        return f'%license {self.path}'


class DataFile(File):
    """A data file in a package."""

    _icon = '💽'

    @property
    def dirs(self) -> list[Path]:
        rel = RPM.datadir() / RPM.name()
        if self.path.is_relative_to(rel):
            for p in self.path.relative_to(rel).parents:
                yield rel / p

    @classmethod
    def istype(cls, ins) -> bool:
        if ins.tag not in ['doc', 'devel', 'runtime', 'i18n']:
            if ins.category == 'data':
                return ins.path.is_relative_to(RPM.datadir() / RPM.name())
            if ins.category == 'targets' and ins.tag != 'runtime':
                if not ins.path.is_relative_to(RPM.mandir()) \
                        and not ins.path.is_relative_to(RPM.docdir()) \
                        and not ins.path.is_relative_to(RPM.datadir() / 'doc'):
                    return True

        return False


class LangFile(File):
    """A translation file in a package."""

    _icon = '🌐'

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.tag == 'i18n'

    def __str__(self):
        """Return the RPM file listing string prefixed with %lang(locale)."""
        return f'%lang({self.locale}) {self.path}'

    @property
    def displaystr(self) -> str:
        """Display string containing the translation file’s locale."""
        return f'🌐 {self.locale}  {self.path.name}'

    @property
    def locale(self) -> str:
        """The locale of this translation file."""
        return self.path.relative_to(RPM.localedir()).parts[0]

    @property
    def isunwanted(self) -> bool:
        return not self.path.parent.is_dir()


class DocFile(File):
    """A documentation file."""

    _icon = '📖'

    @property
    def dirs(self) -> list[Path]:
        rel = RPM.docdir()
        for p in self.path.relative_to(rel).parents:
            if len(p.parts) > 0:
                yield rel / p

    @classmethod
    def istype(cls, ins) -> bool:
        for sc in cls.__subclasses__():
            if sc.istype(ins):
                return False

        return ins.tag == 'doc' and ins.category != None

    @cached_property
    def path(self) -> Path:
        """The documentation file’s adjusted install path."""
        p = self._path
        docdir = RPM.docdir()
        name = RPM.name()
        if not p.is_relative_to(docdir / name):
            if p.is_relative_to(docdir.parent / name):
                return docdir / name / p.relative_to(docdir.parent / name)
        return p


class ExtraDocFile(DocFile):
    """An additional doc file found in the project root."""

    def __str__(self) -> str:
        return f'%doc {self.buildpath.name}'

    @property
    def dirs(self) -> list[Path]:
        return []

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.tag == 'doc' and ins.category == None


class ManFile(DocFile):
    """A manpage file."""

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.path.is_relative_to(RPM.mandir())

    @property
    def dirs(self) -> set:
        return set()

    def __str__(self):
        """Return the RPM file listing string with %ext_man appended."""
        return f'{self.path}{RPM.ext_man()}'

    @property
    def displaystr(self) -> str:
        """Display string with %ext_man appended."""
        return f'{self._icon}  {self.path.name}{RPM.ext_man()}'


class ShellCompletion(DataFile):
    """A shell completion file."""

    _icon = '🐚'

    @classmethod
    def istype(cls, ins) -> bool:
        return False


class BashCompletion(ShellCompletion):
    """A bash completion file."""

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.path.is_relative_to(
            RPM.datadir() / 'bash-completion' / 'completions'
        )


class FishCompletion(ShellCompletion):
    """A fish completion file."""

    @classmethod
    def istype(cls, ins) -> bool:
        return ins.path.is_relative_to(
            RPM.datadir() / 'fish' / 'vendor_completions.d'
        )


class ZshCompletion(ShellCompletion):
    """A zsh completion file."""

    @classmethod
    def istype(cls, ins) -> bool:
        if ins.path.is_relative_to(RPM.datadir() / 'zsh' / 'site-functions'):
            # Check if it looks like a completion script.
            with open(ins.buildpath, 'rb') as zf:
                return zf.read(8) == b'#compdef'

        return False


@dataclass(frozen=True)
class InstallFile:
    """A Meson installed file target."""

    category: str | None
    buildpath: Path
    path: Path
    tag: str
    target_type: str | None
    dependencies: list[Dependency] | None

    def get_file(self) -> File:
        """Return an instance of the appropriate File class for this file."""

        if not (RPM.buildroot() / self.path.relative_to('/')).exists():
            if self.category:
                return UninstalledFile(self.buildpath, self.path)

        def iter_subs(c):
            for sc in c.__subclasses__():
                yield sc
                yield from iter_subs(sc)

        for sc in iter_subs(File):
            if sc.istype(self):
                return sc(self.buildpath, self.path)

        return File(self.buildpath, self.path)


class Pkg(Depends):
    """A to-be-built package."""

    _name = None
    _suffix = None
    _description = None
    _summary = None

    @classmethod
    def want_file(cls, f: File) -> bool:
        """Return whether this is a good Pkg class for the given file."""
        return type(f) in cls._filetypes

    def accept_file(self, f: File) -> bool:
        """Return whether this Pkg accepts the given file."""
        return True

    def __init__(self):
        """Return a Pkg object.

        Keyword arguments:
        name -- The name of the package.
        """
        self._files = set()
        self._macros = set()
        super().__init__()

    def __str__(self):
        """Return the RPM spec representation of the package."""
        if len(self._files) == 0:
            return ''

        return '\n'.join(
            (
                *([
                    *[f'%package -n {self.name}'],
                    f'Summary: {self.summary}',
                    *[f'Requires: {dep}'
                      for dep in sorted(self.merged_requires())],
                    *[f'Provides: {prov}'
                      for prov in sorted(self.merged_provides())],
                    *[f'Supplements: {sup}'
                      for sup in sorted(self.merged_supplements())],
                    *[f'Enhances: {sup}'
                      for sup in sorted(self.merged_enhances())],
                    *([f'BuildArch: {self.buildarch}']
                      if self.buildarch else []),
                    f'\n%description -n {self.name}\n{self.description}\n',
                ] if type(self) is not Pkg else []),
                *self.macros,
                r'%files' + (f' -n {self.name}' if self.name else ''),
                *[f'%dir {d}' for d in sorted(self.dirs)],
                *[str(f) for f in sorted(self.files, key=str)
                  if not f.isunwanted],
            )
        )

    @property
    def displaystr(self):
        """A string representation of the package meant for display."""
        if len(self._files) == 0:
            return ''

        def fmt_flist():
            dlist = defaultdict(list)

            for f in self.files:
                dlist[f.path.parent].append(f)

            for d, fs in sorted(dlist.items(), key=lambda x: x[0]):
                yield f'  📁 {d} ({len(fs)},' \
                    f' {sizefmt(sum(f.size for f in fs))})'
                for f in sorted(fs, key=str):
                    yield f'    {'🗑️' if f.isunwanted else ''}{f.displaystr}'
                    if f.oldpath is not None:
                        yield f'          moved from {f.oldpath}'

        def fmt_deps():
            items = [
                ('Requires:', self.merged_requires()),
                ('Provides:', self.merged_provides()),
                ('Supplements:', self.merged_supplements()),
                ('Enhances:', self.merged_enhances()),
                ('Macros:', self.macros),
            ]
            for k, v in items:
                if len(v) > 0:
                    yield k
                    for dep in sorted(v):
                        yield f'   {dep}'

        return '\n   '.join(
            (
                f'📦 {self.name or RPM.name()}',
                *([f'Summary: {self.summary}']
                  if type(self) is not Pkg else []),
                *fmt_deps(),
                *([f'BuildArch: {self.buildarch}'] if self.buildarch else []),
                *([f'Description:\n      {"\n      ".join(
                    self.description.split("\n"))}']
                    if type(self) is not Pkg else []),
                *([f'📁 Directories ({len(self.dirs)}):']
                  if len(self.dirs) > 0 else []),
                *[f'  📁 {d}' for d in sorted(self.dirs)],
                *([f'📁 Files ({len(self.files)}, '
                    f'{sizefmt(sum(f.size for f in self.files))}):']
                    if len(self.files) > 0 else []),
                *fmt_flist(),
            )
        )

    @property
    def name(self) -> str:
        """The name of the package."""
        return (self._name or RPM.name()) + \
            ('-' + self._suffix if self._suffix else '')

    @property
    def summary(self) -> str:
        """The package’s Summary."""
        return self._summary

    @property
    def description(self) -> str:
        """The package’s Description."""
        rd = RPM.description()
        return (rd + '\n\n' if rd else '') + (self._description or '')

    @property
    def buildarch(self) -> str | None:
        """The package’s BuildArch if appropriate, else None."""
        if all(not f.isarch for f in self.files):
            return 'noarch'
        return None

    def add_file(self, f: File) -> None:
        """Add a File to this package."""
        self._files.add(f)

    def remove_file(self, f: File) -> None:
        """Remove a File from this package."""
        self._files.discard(f)

    @property
    def files(self) -> set:
        """A set of the package’s File objects."""
        return set(self._files)

    @property
    def dirs(self) -> set:
        """A set of directories owned by this package."""
        return set.union(*(set(f.dirs) for f in self.files))

    @property
    def size(self) -> int:
        """The total size in bytes of all files in this package."""
        return sum(f.size for f in self.files)

    def _merge_deps(self, which: str) -> set:
        """Return a union of dependencies of this package and its files."""
        tgt = getattr(self, which)
        return tgt.union(*(f.requires for f in self.files))

    merged_requires = partialmethod(_merge_deps, '_requires')
    merged_provides = partialmethod(_merge_deps, '_provides')
    merged_supplements = partialmethod(_merge_deps, '_supplements')
    merged_enhances = partialmethod(_merge_deps, '_enhances')

    @property
    def macros(self) -> set:
        """The macros to be printed for this package."""
        return self._macros.union(*[f.macros for f in self.files])

    def want_merge(self, other) -> bool:
        """Return whether this package should be merged with another one."""
        return False

    def add_pkg_deps(self, others: list) -> None:
        """Adds appropriate dependencies on the list of other packages."""


class DocPkg(Pkg):
    """Documentation package."""

    _suffix = 'doc'
    _summary = r'Documentation for %{name}'
    _description = r'This package contains the documentation for %{name}.'
    _filetypes = {DocFile, ManFile}

    def want_merge(self, other: Pkg):
        if self.size >= (other.size * 0.4) and self.size > 100 * 1024:
            return False

        return type(other) in [Pkg, DevelPkg]

    def add_pkg_deps(self, others: list[Pkg]) -> None:
        for pkg in others:
            if type(pkg) == Pkg:
                dep = Dependency(r'%{name} == %{version}')
                self.add_requires(dep)
                self.add_enhances(dep)
                return
            elif type(pkg) == DevelPkg:
                dep = Dependency(f'{pkg.name} == %{{version}}')
                self.add_requires(dep)
                self.add_requires(dep)
                return


class DevelPkg(Pkg):
    """Development package."""

    _suffix = 'devel'
    _summary = r'Development files for %{name}'
    _description = r'This package contains the development files for %{name}.'
    _filetypes = {DevelFile, DevelLibFile}
    _maintypes = []

    def add_pkg_deps(self, others: list[Pkg]) -> None:
        for pkg in others:
            if type(pkg) == SharedLibPkg:
                for f in self.files:
                    for of in pkg.files:
                        if f.path.parent == of.path.parent:
                            fn = f.path.with_suffix('').name
                            on = of.path.name.split('.so.')[0]
                            if fn == on:
                                self.add_requires(
                                    Dependency(f'{pkg.name} == %{{version}}'))


class DataPkg(Pkg):
    """Data package."""

    _suffix = 'data'
    _summary = r'Data files for %{name}'
    _description = r'This package contains the data files for %{name}.'
    _filetypes = {DataFile}

    def add_pkg_deps(self, others: list[Pkg]) -> None:
        for pkg in others:
            if type(pkg) == Pkg:
                self.add_requires(Dependency(r'%{name} == %{version}'))
                pkg.add_requires(Dependency(f'{self.name} == %{{version}}'))

    def want_merge(self, other: Pkg) -> bool:
        if self.size < 50 * 1024 ** 2:
            return type(other) == Pkg
        return False


class LangPkg(Pkg):
    """Translation package."""

    _suffix = 'lang'
    _summary = r'Translations for %{name}'
    _description = r'This package contains translations for %{name}.'
    _filetypes = {LangFile}

    def add_pkg_deps(self, others: list[Pkg]) -> None:
        for pkg in others:
            if type(pkg) == Pkg:
                self.add_requires(Dependency(r'%{name} == %{version}'))
                self.add_provides(
                    Dependency(r'%{name}-lang-all == %{{version}}'))
                return
            elif type(pkg) == SharedLibPkg:
                self.add_requires(Dependency(f'%{name} == %{version}'))
                self.add_provides(
                    Dependency(f'%{name}-lang-all == %{{version}}'))


class SharedLibPkg(Pkg):
    """Shared library package."""

    _summary = r'Shared library for %{name}'
    _description = r'This package contains a shared library for %{name}.'
    _filetypes = {SharedLibFile}

    @staticmethod
    def pkgname(lib: SharedLibFile) -> str:
        soname, sover = lib.soname
        return soname + sover.replace('.', '_')

    def accept_file(self, f: SharedLibFile) -> bool:
        return self.name == self.pkgname(f)

    @property
    def name(self) -> str | None:
        for lib in self.files:
            if type(lib) == SharedLibFile:
                return self.pkgname(lib)
        return None

    @property
    def summary(self) -> str:
        for lib in self.files:
            if type(lib) == SharedLibFile:
                if lib.soname[0] == RPM.name():
                    return RPM.summary()
        return self._summary

    @property
    def macros(self) -> set[str]:
        return  {f'%ldconfig_scriptlets -n {self.name}'}


class LicensePkg(Pkg):
    """Package containing license files."""

    _suffix = 'licenses'
    _summary = r'Licenses for %{name}'
    _description = r'This package contains the license files for %{name}.'
    _filetypes = {LicenseFile}

    def want_merge(self, other: Pkg) -> bool:
        return type(other) in {Pkg, SharedLibPkg, DevelPkg}


class ShellCompletionPkg(Pkg):
    """Package containing shell completion files."""

    _shell = None
    _completion_pkg = None
    _filetypes = {}

    def __init__(self, *args, **kwargs):
        self._suffix = f'{self._shell}-completion'
        self._summary = f'{self._shell.title()} completion for %{{name}}'
        self._description = \
            f'This package contains {self._shell} completion for %{{name}}.'
        self._implicit_requires = _shell
        self._implicit_supplements = \
            f'({self._completion_pkg or self._shell} and %{{name}})'
        super().__init__(self, *args, **kwargs)


class BashCompletionPkg(ShellCompletionPkg):
    """Package containing bash completion files."""

    _shell = 'bash'
    _completion_pkg = 'bash-completion'
    _filetypes = {BashCompletion}


class FishCompletionPkg(ShellCompletionPkg):
    """Package containing fish completion files."""

    _shell = 'fish'
    _filetypes = {FishCompletion}


class ZshCompletionPkg(ShellCompletionPkg):
    """Package containing zsh completion files."""

    _shell = 'zsh'
    _filetypes = {ZshCompletion}


def have_license_meta(data: dict) -> bool:
    """Return whether the Meson project specifies license files."""
    lics = data['projectinfo']['license']
    lic_files = data['projectinfo']['license_files']
    return (len(lics) and 'unknown' not in lics) and len(lic_files)


if __name__ == "__main__":
    log('\n--- 🪄 Dynamic spec -------------------------')

    meson = run(('meson', 'introspect', '-ai', sys.argv[1]),
                    capture_output=True, check=True)
    data = json.loads(meson.stdout)

    assert have_license_meta(data), """

    Meson project has missing or incomplete license metadata!
    Please file an issue upstream.

    In the meantime, you may add this to the spec file:

    ```
    %prep -a
    # Add SPDX identifier (use multiple times for additional licenses)
    meson rewrite kwargs add project / license SPDX-Goes-Here

    # Add license files
    meson rewrite kwargs add project / license_files LICENSE
    ```

    """

    deps = {
        d['name'] : Dependency(d['name'], d['type'], d['link_args'])
        for d in data['dependencies']
    }

    def installed_files():
        for src, dst in data['installed'].items():
            buildpath = Path(src)
            path = Path(dst)
            for cat, v in data['install_plan'].items():
                tag = v[src]['tag'] if src in v else None
                if tag not in ['i18n'] and cat == 'targets' and src in v:
                    for t in data['targets']:
                        if t['installed'] and src in t['filename']:
                            if dst in t['install_filename']:
                                for f in t['install_filename']:
                                    yield InstallFile(
                                        category=cat,
                                        buildpath=buildpath,
                                        path=Path(f),
                                        tag=tag,
                                        target_type=t['type'],
                                        dependencies=[
                                            deps[d] for d in t['dependencies']
                                            if d in deps
                                        ],
                                    )
                            else:
                                yield InstallFile(
                                    category=cat,
                                    buildpath=buildpath,
                                    path=path,
                                    tag=v[src]['tag'],
                                    target_type=t['type'],
                                    dependencies=[
                                        deps[d] for d in t['dependencies']
                                        if d in deps
                                    ],
                                )
                            break
                    break
                elif src in v:
                    yield InstallFile(
                        category=cat,
                        buildpath=buildpath,
                        path=path,
                        tag=tag,
                        target_type=None,
                        dependencies=None,
                    )
                    break

        for doc in Path('.').glob('README*'):
            yield InstallFile(
                category=None,
                buildpath=doc,
                path=RPM.docdir() / RPM.name() / doc.name,
                tag='doc',
                target_type=None,
                dependencies=None,
            )

    pkgs = []
    main = Pkg()
    for ins in installed_files():
        f = ins.get_file()
        for pkg in pkgs:
            if pkg.want_file(f) and pkg.accept_file(f):
                pkg.add_file(f)
                break
        else:
            for sc in Pkg.__subclasses__():
                if sc.want_file(f):
                    pkg = sc()
                    pkg.add_file(f)
                    pkgs.append(pkg)
                    break
            else:
                main.add_file(f)

    if len(main.files) > 0:
        pkgs.insert(0, main)

    delset = set()
    for pkg in pkgs:
        others = [p for p in pkgs if p != pkg]
        for opkg in others:
            if pkg.want_merge(opkg) and \
                    len([p for p in others if type(p) == type(opkg)]) == 1:
                for f in pkg.files:
                    opkg.add_file(f)
                delset.add(pkg)
                break
        else:
            pkg.add_pkg_deps(others)

    for pkg in delset:
        del pkgs[pkgs.index(pkg)]

    tomove = []
    todelete = []
    for pkg in pkgs:
        print(pkg)
        log(pkg.displaystr)
        for f in pkg.files:
            if f.isunwanted:
                todelete.append(f)
            elif f.oldpath:
                tomove.append(f)

    log('---------------------------------------------')
    log(f'Total size: {sizefmt(sum(p.size for p in pkgs))}', prefix='🧮')
    log('---------------------------------------------')

    if len(todelete):
        log(f'Deleting {len(todelete)} file(s)...', prefix='🗑️')
        for f in todelete:
            dst = RPM.buildroot() / f.path.relative_to('/')
            dst.unlink()
            try:
                for parent in dst.parents:
                    parent.rmdir()
            except OSError:
                pass

    if len(tomove):
        log(f'Moving {len(tomove)} file(s)...', prefix='🚚')
        for f in tomove:
            if f.oldpath:
                src = RPM.buildroot() / f.oldpath.relative_to('/')
                dst = RPM.buildroot() / f.path.relative_to('/')
                log(src, ' -> ', dst)
                try:
                    dst.parent.mkdir(parents=True, exist_ok=True)
                    src.rename(dst)
                    for parent in src.parents:
                        parent.rmdir()
                except OSError:
                    pass
