#!/usr/bin/python3


from __future__ import annotations

import logging
import sys
import time
from typing import Any

from slpkg.binaries.dependencies import Required
from slpkg.binaries.install import Packages
from slpkg.choose_dependencies import ChooseDependencies
from slpkg.choose_packages import Choose
from slpkg.config import config_load
from slpkg.load_data import LoadData
from slpkg.repositories import Repositories
from slpkg.sbo.dependencies import Requires
from slpkg.sbo.slackbuild import Slackbuilds
from slpkg.upgrade import Upgrade
from slpkg.utilities import Utilities
from slpkg.views.display import View
from slpkg.views.process import ViewProcess

logger = logging.getLogger(__name__)


class AllRepos:  # pylint: disable=[R0902,R0903]
    """Install or upgrade packages across all priority-enabled repositories in one pass."""

    def __init__(
        self,
        options: dict[str, Any],
        packages: list[str] | None = None,
        mode: str = "upgrade",
    ) -> None:
        self.options = options
        self.mode = mode
        self.packages = packages or []
        self.repos = Repositories()
        self.load_data = LoadData()
        self.utils = Utilities()
        self.red = config_load.red
        self.cyan = config_load.cyan
        self.endc = config_load.endc
        self.all_repos_confirm: str = config_load.all_repos_confirm
        self.sbo_repos: set[str] = {self.repos.sbo_repo_name, self.repos.ponce_repo_name}

    def priority_repos(self) -> list[str]:
        """Return repo names in priority order.

        If --repos-priority was passed on the command line, that list is used
        for this run only (transient override).
        Otherwise, if [ALL_REPOS] PRIORITY is configured, only those repos are
        returned (in the declared order, filtered to enabled ones).
        If PRIORITY is also empty, all enabled repositories are returned in the
        order they appear in repositories.toml.
        """
        enabled = {name for name, cfg in self.repos.repositories.items() if cfg.get("enable")}
        override: list[str] = self.options.get("option_repos_priority") or []
        if override:
            return [name.lower() for name in override if name.lower() in enabled]
        enabled_list = [name for name, cfg in self.repos.repositories.items() if cfg.get("enable")]
        if self.repos.all_repos_priority:
            enabled_set = set(enabled_list)
            return [name.lower() for name in self.repos.all_repos_priority if name.lower() in enabled_set]
        return enabled_list

    def _load_packages(
        self, priority_repos: list[str], view_process: ViewProcess, quiet: bool
    ) -> list[tuple[str, dict[str, Any], list[str], bool]]:
        """Phase 1: load data and collect matching packages per repository.

        For upgrade mode: returns only packages with available upgrades.
        For install mode: returns packages whose name contains any of the search terms.

        Returns:
            List of (repo_name, data, packages, is_sbo) for repos with matches.
        """
        if not quiet:
            view_process.message("Databases loading")

        raw_repo_data: list[tuple[str, dict[str, Any], list[str], bool]] = []
        for repo_name in priority_repos:
            data = self.load_data.load(repo_name, message=False)

            if self.mode == "upgrade":
                upgrade = Upgrade(repo_name, data)
                new_packages: list[str] = []
                upgrade_packages: list[str] = []
                for p in upgrade.packages():
                    if p.endswith("_Added."):
                        new_packages.append(p[:-len("_Added.")])
                    elif not p.endswith("_Removed."):
                        upgrade_packages.append(p)
                # New packages first (install-new before upgrade-all, like slackpkg).
                packages = new_packages + upgrade_packages
            else:
                # install: filter by search terms (case-sensitive substring match)
                packages = [
                    p for p in data
                    if any(term in p for term in self.packages)
                ]

            if not packages:
                logger.info("No %s packages in '%s'.", self.mode, repo_name)
                continue
            is_sbo = repo_name in self.sbo_repos
            raw_repo_data.append((repo_name, data, packages, is_sbo))

        if not quiet:
            view_process.done()

        return raw_repo_data

    @staticmethod
    def _pkg_name(key: str) -> str:
        """Strip repo suffix from a qualified key: 'vlc [alien]' → 'vlc'."""
        return key.rsplit(" [", 1)[0] if " [" in key else key

    @staticmethod
    def _qualify_dep(dep: str, dep_to_repo: dict[str, str]) -> str:
        """Return 'dep [repo]' if a source repo is known, otherwise just 'dep'."""
        rn = dep_to_repo.get(dep, "")
        return f"{dep} [{rn}]" if rn else dep

    @staticmethod
    def _conflict_packages(
        raw_repo_data: list[tuple[str, dict[str, Any], list[str], bool]]
    ) -> set[str]:
        """Return package names that appear in more than one repo (install mode pre-scan)."""
        count: dict[str, int] = {}
        for _, _, packages, _ in raw_repo_data:
            for pkg in packages:
                count[pkg] = count.get(pkg, 0) + 1
        return {pkg for pkg, n in count.items() if n > 1}

    def _build_combined_data(  # pylint: disable=[R0914]
        self, raw_repo_data: list[tuple[str, dict[str, Any], list[str], bool]]
    ) -> tuple[
        dict[str, Any],           # combined_data
        list[str],                # combined_packages
        dict[str, str],           # package_to_repo
        dict[str, tuple[dict[str, Any], bool]],  # repo_data_map
        dict[str, list[str]],     # conflicts: pkg -> [repo, ...]
    ]:
        """Build combined lookup structures from all repos.

        Upgrade mode: first repo wins on conflict (logs a warning).
        Install mode: conflicts produce qualified keys 'pkg [repo]' so the user
        can choose which repository's version to install.
        """
        combined_data: dict[str, Any] = {}
        combined_packages: list[str] = []
        package_to_repo: dict[str, str] = {}
        repo_data_map: dict[str, tuple[dict[str, Any], bool]] = {}
        conflicts: dict[str, list[str]] = {}
        seen_plain: set[str] = set()

        conflict_pkgs: set[str] = (
            self._conflict_packages(raw_repo_data) if self.mode == "install" else set()
        )

        for repo_name, data, packages, is_sbo in raw_repo_data:
            repo_data_map[repo_name] = (data, is_sbo)
            for pkg in packages:
                is_conflict = pkg in conflict_pkgs
                if is_conflict or pkg not in seen_plain:
                    key = f"{pkg} [{repo_name}]"
                    combined_data[key] = data[pkg]
                    combined_packages.append(key)
                    package_to_repo[key] = repo_name
                    if is_conflict:
                        conflicts.setdefault(pkg, []).append(repo_name)
                    else:
                        conflicts[pkg] = [repo_name]
                        seen_plain.add(pkg)
                else:
                    # Upgrade-mode conflict: first repo already added, skip duplicate.
                    first_key = next(k for k in package_to_repo if self._pkg_name(k) == pkg)
                    conflicts[pkg].append(repo_name)
                    logger.warning(
                        "Package '%s' found in '%s' and '%s'; using '%s'.",
                        pkg, package_to_repo[first_key], repo_name, package_to_repo[first_key],
                    )

        return combined_data, combined_packages, package_to_repo, repo_data_map, conflicts

    def _resolve_selected_deps(
        self,
        selected_packages: list[str],
        package_to_repo: dict[str, str],
        repo_data_map: dict[str, tuple[dict[str, Any], bool]],
    ) -> tuple[list[str], dict[str, str]]:
        """Resolve deps for all selected packages.

        Returns:
            (all_deps_ordered, dep_to_repo) — deduplicated deps in insertion order
            and a mapping of each dep to its source repository.
        """
        all_deps: list[str] = []
        dep_to_repo: dict[str, str] = {}

        for pkg in selected_packages:
            repo_name = package_to_repo[pkg]
            data, is_sbo = repo_data_map[repo_name]
            real_name = self._pkg_name(pkg)
            if is_sbo:
                pkg_deps: tuple[str, ...] = Requires(data, real_name, self.options).resolve()
            else:
                pkg_deps = Required(data, real_name, self.options).resolve()
            for d in pkg_deps:
                if d not in dep_to_repo:
                    dep_to_repo[d] = repo_name
            all_deps.extend(pkg_deps)

        return list(dict.fromkeys(all_deps)), dep_to_repo

    def _build_dep_ui_data(
        self,
        all_deps_ordered: list[str],
        dep_to_repo: dict[str, str],
        repo_data_map: dict[str, tuple[dict[str, Any], bool]],
        auto_moved_set: set[str],
    ) -> tuple[dict[str, Any], list[str]]:
        """Build the dep data dict and display list for the dependency selector.

        Returns:
            (combined_dep_data, deps_for_ui) — a {qualified_dep: pkg_data} dict and
            the ordered list of qualified dep names to show in the UI (auto-moved excluded).
        """
        combined_dep_data: dict[str, Any] = {
            self._qualify_dep(dep, dep_to_repo): repo_data_map[rn][0].get(dep, {})
            for dep in all_deps_ordered
            if (rn := dep_to_repo.get(dep, "")) in repo_data_map
        }
        deps_for_ui = [
            self._qualify_dep(d, dep_to_repo)
            for d in all_deps_ordered
            if d not in auto_moved_set
        ]
        return combined_dep_data, deps_for_ui

    def _select_packages_and_deps(  # pylint: disable=[R0913,R0917,R0914]
        self,
        combined_data: dict[str, Any],
        combined_packages: list[str],
        package_to_repo: dict[str, str],
        repo_data_map: dict[str, tuple[dict[str, Any], bool]],
        view_process: ViewProcess,
    ) -> tuple[list[str], list[str], list[str], dict[str, str]] | None:
        """Phase 2: ONE combined package selection + ONE combined dependency selection.

        Returns:
            (actual_packages, auto_moved, user_selected_deps, dep_to_repo)
            or None if the user deselected everything.
        """
        ordered = self.mode == "install"
        selected_packages = Choose(self.options, "all-repos").packages(
            combined_data, combined_packages, self.mode, ordered=ordered
        )
        if not selected_packages:
            return None

        all_deps_ordered, dep_to_repo = self._resolve_selected_deps(
            selected_packages, package_to_repo, repo_data_map
        )
        deps_set = set(all_deps_ordered)

        # selected_packages carry qualified keys ("qemu [ponce]"); deps_set contains
        # plain names — strip the suffix before comparing.
        auto_moved = [p for p in selected_packages if self._pkg_name(p) in deps_set]
        actual_packages = [p for p in selected_packages if self._pkg_name(p) not in deps_set]
        auto_moved_set = {self._pkg_name(p) for p in auto_moved}

        combined_dep_data, deps_for_ui = self._build_dep_ui_data(
            all_deps_ordered, dep_to_repo, repo_data_map, auto_moved_set
        )

        choose_deps = ChooseDependencies("all-repos", combined_dep_data, self.options, self.mode)
        if deps_for_ui:
            view_process.message("Resolving dependencies")
        # Strip the repo suffix back to plain names before returning.
        user_selected_deps = [self._pkg_name(d) for d in choose_deps.choose(deps_for_ui, view_process)]

        logger.info(
            "Combined selection: %d packages, %d deps (%d auto-moved).",
            len(actual_packages), len(user_selected_deps), len(auto_moved),
        )
        return actual_packages, auto_moved, user_selected_deps, dep_to_repo

    def _split_per_repo(  # pylint: disable=[R0913,R0914,R0917]
        self,
        raw_repo_data: list[tuple[str, dict[str, Any], list[str], bool]],
        actual_packages: list[str],
        auto_moved: list[str],
        user_selected_deps: list[str],
        dep_to_repo: dict[str, str],
        package_to_repo: dict[str, str],
    ) -> list[tuple[str, dict[str, Any], list[str], list[str], list[str]]]:
        """Split combined selection back into per-repo buckets for display and execute."""
        repo_data: list[tuple[str, dict[str, Any], list[str], list[str], list[str]]] = []

        for repo_name, raw_data, _, _is_sbo in raw_repo_data:
            # Strip any "[repo]" suffix so actual package names are passed to execute.
            repo_actual_pkgs = [
                self._pkg_name(p) for p in actual_packages if package_to_repo.get(p) == repo_name
            ]
            repo_auto_moved = [
                self._pkg_name(p) for p in auto_moved if package_to_repo.get(p) == repo_name
            ]
            repo_selected_pkgs = repo_actual_pkgs + repo_auto_moved

            if not repo_selected_pkgs:
                continue

            repo_user_deps = [d for d in user_selected_deps if dep_to_repo.get(d) == repo_name]
            repo_final_deps = list(dict.fromkeys(repo_auto_moved + repo_user_deps))

            repo_data.append((repo_name, raw_data, repo_actual_pkgs, repo_final_deps, repo_selected_pkgs))
            logger.info(
                "Repo '%s': %d packages, %d deps.", repo_name, len(repo_actual_pkgs), len(repo_final_deps),
            )

        return repo_data

    def _print_conflicts(self, conflicts: dict[str, list[str]], selected: set[str] | None = None) -> None:
        """Print a warning for packages found in more than one priority repository.

        If *selected* is provided, only packages in that set are reported.
        """
        multi = {
            pkg: repos
            for pkg, repos in conflicts.items()
            if len(repos) > 1 and (selected is None or pkg in selected)
        }
        if not multi:
            return
        col_width = max(len(pkg) for pkg in multi) + 2
        if self.options.get("option_repos_priority") or self.repos.all_repos_priority:
            order_desc = "PRIORITY list order"
        else:
            order_desc = "repositories.toml order"
        print(f"{self.red}Warning{self.endc}: the following packages were found in multiple repositories.")
        print(f"Only the first repository in {order_desc} is used:\n")
        for pkg, repos in multi.items():
            quoted = [f"'{r}'" for r in repos]
            all_repos = ", ".join(quoted[:-1]) + f" and {quoted[-1]}" if len(quoted) > 1 else quoted[0]
            print(f"   {pkg:<{col_width}}: found in {all_repos} — using '{repos[0]}'")
        print()

    def _execute_sbo_repos(
        self,
        sbo_data: list[tuple[str, dict[str, Any], list[str], list[str], list[str]]],
        exec_options: dict[str, Any],
        per_repo_confirm: bool,
        view: View,
    ) -> None:
        """Execute installs/upgrades for SBo repos, one per repo (build environment is repo-specific)."""
        action = self.mode.capitalize()
        for repo_name, data, _, final_deps, selected_packages in sbo_data:
            color_repo = f"{self.cyan}{repo_name}{self.endc}"
            if per_repo_confirm:
                view.question(f"{action} packages from '{color_repo}'?")
            logger.info("%sing SlackBuilds from '%s' (%d packages).", action, repo_name, len(selected_packages))
            Slackbuilds(repo_name, data, selected_packages, exec_options, mode=self.mode,
                        preselected_deps=final_deps, repo_label=color_repo).execute(show_finished=False)

    @staticmethod
    def _merge_binary_repos_data(
        bin_data: list[tuple[str, dict[str, Any], list[str], list[str], list[str]]],
    ) -> tuple[str, dict[str, Any], list[str], list[str], dict[str, str], str]:
        """Merge multiple binary repo buckets into a single combined data set.

        Returns:
            (first_repo_name, merged_data, merged_selected, merged_deps, pkg_to_repo, repo_label)
        """
        merged_data: dict[str, Any] = {}
        merged_selected: list[str] = []
        merged_deps: list[str] = []
        pkg_to_repo: dict[str, str] = {}
        repo_names: list[str] = []
        first_repo_name = bin_data[0][0]

        for repo_name, data, _, final_deps, selected_packages in bin_data:
            merged_data.update(data)
            for p in selected_packages:
                if p not in pkg_to_repo:
                    merged_selected.append(p)
                    pkg_to_repo[p] = repo_name
            for d in final_deps:
                if d not in pkg_to_repo:
                    merged_deps.append(d)
                    pkg_to_repo[d] = repo_name
            repo_names.append(repo_name)

        return first_repo_name, merged_data, merged_selected, merged_deps, pkg_to_repo, ", ".join(repo_names)

    def _execute_binary_repos(  # pylint: disable=[R0914]
        self,
        bin_data: list[tuple[str, dict[str, Any], list[str], list[str], list[str]]],
        exec_options: dict[str, Any],
        per_repo_confirm: bool,
        view: View,
    ) -> None:
        """Execute installs/upgrades for binary repos.

        When per_repo_confirm is False and there are multiple repos, all binary repos
        are merged into a single Packages call for one combined download+install pass.
        """
        action = self.mode.capitalize()
        if len(bin_data) == 1 or per_repo_confirm:
            for repo_name, data, _, final_deps, selected_packages in bin_data:
                color_repo = f"{self.cyan}{repo_name}{self.endc}"
                if per_repo_confirm:
                    view.question(f"{action} packages from '{color_repo}'?")
                logger.info("%sing binary packages from '%s' (%d packages).", action, repo_name, len(selected_packages))
                Packages(repo_name, data, selected_packages, exec_options, mode=self.mode,
                         preselected_deps=final_deps, repo_label=color_repo).execute(show_finished=False)
        else:
            first_repo_name, merged_data, merged_selected, merged_deps, pkg_to_repo, repo_label = (
                self._merge_binary_repos_data(bin_data)
            )
            logger.info("%sing binary packages from '%s' (%d packages).", action, repo_label, len(merged_selected))
            Packages(first_repo_name, merged_data, merged_selected, exec_options, mode=self.mode,
                     preselected_deps=merged_deps, repo_label=repo_label,
                     package_to_repo=pkg_to_repo).execute(show_finished=False)

    def _execute_repos(
        self,
        repo_data: list[tuple[str, dict[str, Any], list[str], list[str], list[str]]],
        view: View,
    ) -> None:
        """Phase 4: confirm and execute, then print a single combined Finished timer."""
        exec_options: dict[str, Any] = dict(self.options)
        exec_options["option_no_view"] = True

        # --check only shows the summary; the view is already printed by run() above.
        if self.options.get("option_check"):
            return

        per_repo_confirm = self.all_repos_confirm == "per_repo"
        if not per_repo_confirm:
            view.question()

        sbo_data = [(rn, d, pkgs, deps, sel) for rn, d, pkgs, deps, sel in repo_data if rn in self.sbo_repos]
        bin_data = [(rn, d, pkgs, deps, sel) for rn, d, pkgs, deps, sel in repo_data if rn not in self.sbo_repos]

        overall_start = time.time()
        if sbo_data:
            self._execute_sbo_repos(sbo_data, exec_options, per_repo_confirm, view)
        if bin_data:
            self._execute_binary_repos(bin_data, exec_options, per_repo_confirm, view)
        self.utils.finished_time(time.time() - overall_start)
        logger.info("all-repos %s completed for %d repositories.", self.mode, len(repo_data))

    def _validate_priority_override(self, override: list[str]) -> None:
        """Check that every repo in the override list exists; exit on the first unknown.

        Args:
            override (list[str]): Repo names supplied via --repos-priority.
        """
        known = {name.lower() for name in self.repos.repositories}
        unknown = [n for n in override if n.lower() not in known]
        if unknown:
            for u in unknown:
                print(f"{self.red}Error{self.endc}: repository '{u}' not found in repositories.toml.")
            sys.exit(1)

    def run(self) -> None:  # pylint: disable=[R0914]
        """Load, select, and process packages across all priority repositories."""
        logger.info("Executing all-repos command (mode=%s).", self.mode)

        if self.mode == "upgrade":
            override: list[str] = self.options.get("option_repos_priority") or []
            if override:
                self._validate_priority_override(override)

        priority_repos = self.priority_repos()
        if not priority_repos:
            print("No enabled repositories found. Enable at least one repository in repositories.toml.")
            return

        logger.info("Priority repos: %s", priority_repos)
        quiet: bool = bool(self.options.get("option_quiet"))
        view_process = ViewProcess()

        raw_repo_data = self._load_packages(priority_repos, view_process, quiet)
        if not raw_repo_data:
            if self.mode == "upgrade":
                print("\nEverything is up-to-date!\n")
            else:
                print(f"\nNo packages found matching: {', '.join(self.packages)}\n")
            return

        combined_data, combined_packages, package_to_repo, repo_data_map, conflicts = (
            self._build_combined_data(raw_repo_data)
        )

        result = self._select_packages_and_deps(
            combined_data, combined_packages, package_to_repo, repo_data_map, view_process
        )
        if result is None:
            if self.mode == "upgrade":
                print("\nEverything is up-to-date!\n")
            return

        actual_packages, auto_moved, user_selected_deps, dep_to_repo = result

        repo_data = self._split_per_repo(
            raw_repo_data, actual_packages, auto_moved, user_selected_deps, dep_to_repo, package_to_repo
        )
        if not repo_data:
            if self.mode == "upgrade":
                print("\nEverything is up-to-date!\n")
            return

        view = View(self.options)
        view_data = [(rn, d, pkgs, deps) for rn, d, pkgs, deps, _ in repo_data]
        view.install_upgrade_packages_all(view_data)

        # For install mode conflicts are shown in the selector (qualified names);
        # the warning is only relevant for upgrade where the user had no choice.
        if self.mode == "upgrade":
            selected_plain = (
                {self._pkg_name(p) for p in actual_packages + auto_moved}
                | set(user_selected_deps)
            )
            self._print_conflicts(conflicts, selected_plain)
        self._execute_repos(repo_data, view)
