# Copyright 2007-2023 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

import errno
import os
import re
from itertools import chain

from portage import cpv_getkey, portage_gid
from portage._sets import SETPREFIX, SetConfigError, get_boolean
from portage._sets.base import EditablePackageSet, PackageSet
from portage.const import USER_CONFIG_PATH, VCS_DIRS, WORLD_FILE, WORLD_SETS_FILE
from portage.env.loaders import ItemFileLoader, KeyListFileLoader
from portage.env.validators import ValidAtomValidator
from portage.localization import _
from portage.locks import lockfile, unlockfile
from portage.util import ensure_dirs, grabfile, normalize_path, write_atomic

__all__ = [
    "ConfigFileSet",
    "StaticFileSet",
    "WorldSelectedPackagesSet",
    "WorldSelectedSet",
    "WorldSelectedSetsSet",
]


class StaticFileSet(EditablePackageSet):
    _operations = ["merge", "unmerge"]
    _repopath_match = re.compile(r".*\$\{repository:(?P<reponame>.+)\}.*")
    _repopath_sub = re.compile(r"\$\{repository:(?P<reponame>.+)\}")

    def __init__(self, filename, greedy=False, dbapi=None):
        super().__init__(allow_repo=True)
        self._filename = filename
        self._mtime = None
        self.description = f"Package set loaded from file {self._filename}"
        self.loader = ItemFileLoader(self._filename, self._validate)
        if greedy and not dbapi:
            self.errors.append(
                _(
                    "%s configured as greedy set, but no dbapi instance passed in constructor"
                )
                % self._filename
            )
            greedy = False
        self.greedy = greedy
        self.dbapi = dbapi

        metadata = grabfile(self._filename + ".metadata")
        key = None
        value = []
        for line in metadata:
            line = line.strip()
            if len(line) == 0 and key is not None:
                setattr(self, key, " ".join(value))
                key = None
            elif line[-1] == ":" and key is None:
                key = line[:-1].lower()
                value = []
            elif key is not None:
                value.append(line)
            else:
                pass
        if key is not None:
            setattr(self, key, " ".join(value))

    def _validate(self, atom):
        return bool(atom[:1] == SETPREFIX or ValidAtomValidator(atom, allow_repo=True))

    def write(self):
        write_atomic(
            self._filename,
            "".join(
                f"{atom}\n"
                for atom in sorted(chain(self._atoms, self._nonatoms), key=str)
            ),
        )

    def load(self):
        try:
            mtime = os.stat(self._filename).st_mtime
        except OSError:
            mtime = None
        if not self._loaded or self._mtime != mtime:
            try:
                data, errors = self.loader.load()
                for fname in errors:
                    for e in errors[fname]:
                        self.errors.append(fname + ": " + e)
            except OSError as e:
                if e.errno != errno.ENOENT:
                    raise
                del e
                data = {}
            if self.greedy:
                atoms = []
                for a in data:
                    matches = self.dbapi.match(a)
                    for cpv in matches:
                        pkg = self.dbapi._pkg_str(cpv, None)
                        atoms.append(f"{pkg.cp}:{pkg.slot}")
                    # In addition to any installed slots, also try to pull
                    # in the latest new slot that may be available.
                    atoms.append(a)
            else:
                atoms = iter(data)
            self._setAtoms(atoms)
            self._mtime = mtime

    def singleBuilder(self, options, settings, trees):
        if "filename" not in options:
            raise SetConfigError(_("no filename specified"))
        greedy = get_boolean(options, "greedy", False)
        filename = options["filename"]
        # look for repository path variables
        match = self._repopath_match.match(filename)
        if match:
            try:
                filename = self._repopath_sub.sub(
                    trees["porttree"].dbapi.treemap[match.groupdict()["reponame"]],
                    filename,
                )
            except KeyError:
                raise SetConfigError(
                    _("Could not find repository '%s'") % match.groupdict()["reponame"]
                )
        return StaticFileSet(filename, greedy=greedy, dbapi=trees["vartree"].dbapi)

    singleBuilder = classmethod(singleBuilder)

    def multiBuilder(self, options, settings, trees):
        rValue = {}
        directory = options.get(
            "directory",
            os.path.join(settings["PORTAGE_CONFIGROOT"], USER_CONFIG_PATH, "sets"),
        )
        name_pattern = options.get("name_pattern", "${name}")
        if "$name" not in name_pattern and "${name}" not in name_pattern:
            raise SetConfigError(_("name_pattern doesn't include ${name} placeholder"))
        greedy = get_boolean(options, "greedy", False)
        # look for repository path variables
        match = self._repopath_match.match(directory)
        if match:
            try:
                directory = self._repopath_sub.sub(
                    trees["porttree"].dbapi.treemap[match.groupdict()["reponame"]],
                    directory,
                )
            except KeyError:
                raise SetConfigError(
                    _("Could not find repository '%s'") % match.groupdict()["reponame"]
                )

        try:
            directory.encode("utf-8", "strict")
        except UnicodeEncodeError:
            raise SetConfigError(
                _(
                    "Directory path contains invalid character(s) for encoding '%s': '%s'"
                )
                % ("utf-8", directory)
            )

        vcs_dirs = set(VCS_DIRS)
        if os.path.isdir(directory):
            directory = normalize_path(directory)

            for parent, dirs, files in os.walk(directory):
                dirs_orig = dirs
                omit_dir = lambda d: dirs_orig.remove(d)
                try:
                    parent.encode("utf-8", "strict")
                except UnicodeEncodeError:
                    continue
                for d in dirs[:]:
                    if d in vcs_dirs or d.startswith(".") or d.endswith("~"):
                        omit_dir(d)
                for filename in files:
                    try:
                        filename.encode("utf-8", "strict")
                    except UnicodeEncodeError:
                        continue
                    if filename.startswith(".") or filename.endswith("~"):
                        continue
                    if filename.endswith(".metadata"):
                        continue
                    filename = os.path.join(parent, filename)[1 + len(directory) :]
                    myname = name_pattern.replace("$name", filename)
                    myname = myname.replace("${name}", filename)
                    rValue[myname] = StaticFileSet(
                        os.path.join(directory, filename),
                        greedy=greedy,
                        dbapi=trees["vartree"].dbapi,
                    )
        return rValue

    multiBuilder = classmethod(multiBuilder)


class ConfigFileSet(PackageSet):
    def __init__(self, filename):
        super().__init__()
        self._filename = filename
        self.description = f"Package set generated from {self._filename}"
        self.loader = KeyListFileLoader(self._filename, ValidAtomValidator)

    def load(self):
        data, errors = self.loader.load()
        self._setAtoms(iter(data))

    def singleBuilder(self, options, settings, trees):
        if "filename" not in options:
            raise SetConfigError(_("no filename specified"))
        return ConfigFileSet(options["filename"])

    singleBuilder = classmethod(singleBuilder)

    def multiBuilder(self, options, settings, trees):
        rValue = {}
        directory = options.get(
            "directory", os.path.join(settings["PORTAGE_CONFIGROOT"], USER_CONFIG_PATH)
        )
        name_pattern = options.get("name_pattern", "sets/package_$suffix")
        if "$suffix" not in name_pattern and "${suffix}" not in name_pattern:
            raise SetConfigError(_("name_pattern doesn't include $suffix placeholder"))
        for suffix in ["keywords", "use", "mask", "unmask"]:
            myname = name_pattern.replace("$suffix", suffix)
            myname = myname.replace("${suffix}", suffix)
            rValue[myname] = ConfigFileSet(os.path.join(directory, "package." + suffix))
        return rValue

    multiBuilder = classmethod(multiBuilder)


class WorldSelectedSet(EditablePackageSet):
    description = "Set of packages and subsets that were directly installed by the user"

    def __init__(self, eroot):
        super().__init__(allow_repo=True)
        self._pkgset = WorldSelectedPackagesSet(eroot)
        self._setset = WorldSelectedSetsSet(eroot)

    def write(self):
        self._pkgset._atoms = self._atoms.copy()
        self._pkgset.write()
        self._setset._nonatoms = self._nonatoms.copy()
        self._setset.write()

    def load(self):
        # Iterating over these sets does not force them to load if they
        # have been loaded previously.
        self._pkgset.load()
        self._setset.load()
        self._setAtoms(chain(self._pkgset, self._setset))

    def lock(self):
        self._pkgset.lock()
        self._setset.lock()

    def unlock(self):
        self._pkgset.unlock()
        self._setset.unlock()

    def cleanPackage(self, vardb, cpv):
        self._pkgset.cleanPackage(vardb, cpv)

    def singleBuilder(self, options, settings, trees):
        return WorldSelectedSet(settings["EROOT"])

    singleBuilder = classmethod(singleBuilder)


class WorldSelectedPackagesSet(EditablePackageSet):
    description = "Set of packages that were directly installed by the user"

    def __init__(self, eroot):
        super().__init__(allow_repo=True)
        self._lock = None
        self._filename = os.path.join(eroot, WORLD_FILE)
        self.loader = ItemFileLoader(self._filename, self._validate)
        self._mtime = None

    def _validate(self, atom):
        return ValidAtomValidator(atom, allow_repo=True)

    def write(self):
        write_atomic(
            self._filename, "".join(sorted((f"{x}\n" for x in self._atoms), key=str))
        )

    def load(self):
        atoms = []
        atoms_changed = False
        try:
            mtime = os.stat(self._filename).st_mtime
        except OSError:
            mtime = None
        if not self._loaded or self._mtime != mtime:
            try:
                data, errors = self.loader.load()
                for fname in errors:
                    for e in errors[fname]:
                        self.errors.append(fname + ": " + e)
            except OSError as e:
                if e.errno != errno.ENOENT:
                    raise
                del e
                data = {}
            atoms = list(data)
            self._mtime = mtime
            atoms_changed = True
        else:
            atoms.extend(self._atoms)

        if atoms_changed:
            self._setAtoms(atoms)

    def _ensure_dirs(self):
        ensure_dirs(
            os.path.dirname(self._filename), gid=portage_gid, mode=0o2750, mask=0o2
        )

    def lock(self):
        if self._lock is not None:
            raise AssertionError("already locked")
        self._ensure_dirs()
        self._lock = lockfile(self._filename, wantnewlockfile=1)

    def unlock(self):
        if self._lock is None:
            raise AssertionError("not locked")
        unlockfile(self._lock)
        self._lock = None

    def cleanPackage(self, vardb, cpv):
        """
        Before calling this function you should call lock and load.
        After calling this function you should call unlock.
        """
        if not self._lock:
            raise AssertionError("cleanPackage needs the set to be locked")

        worldlist = list(self._atoms)
        mykey = cpv_getkey(cpv)
        newworldlist = []
        for x in worldlist:
            if x.cp == mykey:
                matches = vardb.match(x, use_cache=0)
                if not matches or (len(matches) == 1 and matches[0] == cpv):
                    # zap our world entry
                    pass
                else:
                    # others are around; keep it.
                    newworldlist.append(x)
            else:
                # this doesn't match the package we're unmerging; keep it.
                newworldlist.append(x)

        newworldlist.extend(self._nonatoms)
        self.replace(newworldlist)

    def singleBuilder(self, options, settings, trees):
        return WorldSelectedPackagesSet(settings["EROOT"])

    singleBuilder = classmethod(singleBuilder)


class WorldSelectedSetsSet(EditablePackageSet):
    description = "Set of sets that were directly installed by the user"

    def __init__(self, eroot):
        super().__init__(allow_repo=True)
        self._lock = None
        self._filename = os.path.join(eroot, WORLD_SETS_FILE)
        self.loader = ItemFileLoader(self._filename, self._validate)
        self._mtime = None

    def _validate(self, setname):
        return setname.startswith(SETPREFIX)

    def write(self):
        write_atomic(
            self._filename, "".join(sorted((f"{x}\n" for x in self._nonatoms), key=str))
        )

    def load(self):
        atoms_changed = False
        try:
            mtime = os.stat(self._filename).st_mtime
        except OSError:
            mtime = None
        if not self._loaded or self._mtime != mtime:
            try:
                data, errors = self.loader.load()
                for fname in errors:
                    for e in errors[fname]:
                        self.errors.append(fname + ": " + e)
            except OSError as e:
                if e.errno != errno.ENOENT:
                    raise
                del e
                data = {}
            nonatoms = list(data)
            self._mtime = mtime
            atoms_changed = True
        else:
            nonatoms = list(self._nonatoms)

        if atoms_changed:
            self._setAtoms(nonatoms)

    def lock(self):
        if self._lock is not None:
            raise AssertionError("already locked")
        self._lock = lockfile(self._filename, wantnewlockfile=1)

    def unlock(self):
        if self._lock is None:
            raise AssertionError("not locked")
        unlockfile(self._lock)
        self._lock = None

    def singleBuilder(self, options, settings, trees):
        return WorldSelectedSetsSet(settings["EROOT"])

    singleBuilder = classmethod(singleBuilder)
