"""Edubuntu Installer backend."""

from __future__ import annotations

import grp
import json
import os
import pwd
import re
import subprocess
import textwrap
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path

from i18n import _

OutputCallback = Callable[[str], None] | None
ProgressCallback = Callable[[float], None] | None

# Matches the apt summary line, e.g.
# "0 upgraded, 5 newly installed, 0 to remove and 0 not upgraded."
_APT_SUMMARY_RE = re.compile(
    r"(\d+)\s+upgraded,\s*(\d+)\s+newly installed,\s*(\d+)\s+to remove"
)

APP_TITLE = _("Edubuntu Installer")

ICON_PATH = "/usr/share/icons/hicolor/scalable/apps/edubuntu-installer.svg"

HELPER = "/usr/sbin/edubuntu-installer-helper"

METAPACKAGE_CONFIG_PATH = Path(__file__).resolve().parent / "metapackages.json"

_SCHEMA_DIR = Path("/usr/share/glib-2.0/schemas")

GSCHEMA_OVERRIDES = {
    "Preschool": _SCHEMA_DIR / "96-edubuntu-preschool.gschema.override",
    "Primary/Elementary": _SCHEMA_DIR / "96-edubuntu-primary.gschema.override",
    "Secondary/Middle/High School": _SCHEMA_DIR / "96-edubuntu-secondary.gschema.override",
}

_PRESCHOOL_CONF = textwrap.dedent("""\
    [org.gnome.desktop.background:ubuntu]
    picture-uri = 'file:///usr/share/backgrounds/edubuntu_default-preschool.png'
    picture-uri-dark = 'file:///usr/share/backgrounds/edubuntu_default-preschool.png'

    [org.gnome.desktop.screensaver:ubuntu]
    picture-uri = 'file:///usr/share/backgrounds/edubuntu_default-preschool.png'

    [org.gnome.shell:ubuntu]
    favorite-apps = [ 'firefox_firefox.desktop', 'org.kde.gcompris.desktop', 'tuxpaint.desktop', 'org.kde.klettres.desktop', 'org.gnome.Calculator.desktop', 'yelp.desktop' ]
""")

_PRIMARY_CONF = textwrap.dedent("""\
    [org.gnome.desktop.background:ubuntu]
    picture-uri = 'file:///usr/share/backgrounds/edubuntu_default-primary.png'
    picture-uri-dark = 'file:///usr/share/backgrounds/edubuntu_default-primary.png'

    [org.gnome.desktop.screensaver:ubuntu]
    picture-uri = 'file:///usr/share/backgrounds/edubuntu_default-primary.png'

    [org.gnome.shell:ubuntu]
    favorite-apps = [ 'firefox_firefox.desktop', 'org.gnome.Calendar.desktop', 'org.gnome.Nautilus.desktop', 'org.gnome.Calculator.desktop', 'io.github.leolost2605.gradebook.desktop', 'libreoffice-writer.desktop', 'yelp.desktop' ]
""")

_SECONDARY_CONF = textwrap.dedent("""\
    [org.gnome.desktop.background:ubuntu]
    picture-uri = 'file:///usr/share/backgrounds/edubuntu_default-secondary.png'
    picture-uri-dark = 'file:///usr/share/backgrounds/edubuntu_default-secondary.png'

    [org.gnome.desktop.screensaver:ubuntu]
    picture-uri = 'file:///usr/share/backgrounds/edubuntu_default-secondary.png'

    [org.gnome.shell:ubuntu]
    favorite-apps = [ 'firefox_firefox.desktop', 'thunderbird_thunderbird.desktop', 'org.gnome.Nautilus.desktop', 'org.gnome.Notes.desktopo', 'org.gnome.Calculator.desktop', 'io.github.leolost2605.gradebook.desktop', 'libreoffice-writer.desktop', 'yelp.desktop' ]
""")

GSCHEMA_CONTENTS = {
    "Preschool": _PRESCHOOL_CONF,
    "Primary/Elementary": _PRIMARY_CONF,
    "Secondary/Middle/High School": _SECONDARY_CONF,
}

_PRESCHOOL_DCONF = textwrap.dedent("""\
[org/gnome/desktop/background]
picture-uri='file:///usr/share/backgrounds/edubuntu_default-preschool.png'
picture-uri-dark='file:///usr/share/backgrounds/edubuntu_default-preschool.png'

[org/gnome/desktop/screensaver]
picture-uri='file:///usr/share/backgrounds/edubuntu_default-preschool.png'

[org/gnome/shell]
favorite-apps=['firefox_firefox.desktop', 'org.kde.gcompris.desktop', 'tuxpaint.desktop', 'org.kde.klettres.desktop', 'org.gnome.Calculator.desktop', 'yelp.desktop']
""")

_PRIMARY_DCONF = textwrap.dedent("""\
[org/gnome/desktop/background]
picture-uri='file:///usr/share/backgrounds/edubuntu_default-primary.png'
picture-uri-dark='file:///usr/share/backgrounds/edubuntu_default-primary.png'

[org/gnome/desktop/screensaver]
picture-uri='file:///usr/share/backgrounds/edubuntu_default-primary.png'

[org/gnome/shell]
favorite-apps=['firefox_firefox.desktop', 'org.gnome.Calendar.desktop', 'org.gnome.Nautilus.desktop', 'tuxmath.desktop', 'org.kde.ktouch.desktop', 'org.gnome.Calculator.desktop', 'artha.desktop', 'libreoffice-writer.desktop', 'yelp.desktop']
""")

_SECONDARY_DCONF = textwrap.dedent("""\
[org/gnome/desktop/background]
picture-uri='file:///usr/share/backgrounds/edubuntu_default-secondary.png'
picture-uri-dark='file:///usr/share/backgrounds/edubuntu_default-secondary.png'

[org/gnome/desktop/screensaver]
picture-uri='file:///usr/share/backgrounds/edubuntu_default-secondary.png'

[org/gnome/shell]
favorite-apps=['firefox_firefox.desktop', 'thunderbird_thunderbird.desktop', 'org.gnome.Calendar.desktop', 'org.gnome.Nautilus.desktop', 'org.gnome.Calculator.desktop', 'org.gnome.Gnote.desktop', 'libreoffice-writer.desktop', 'yelp.desktop']
""")

DCONF_CONTENTS = {
    "Preschool": _PRESCHOOL_DCONF,
    "Primary/Elementary": _PRIMARY_DCONF,
    "Secondary/Middle/High School": _SECONDARY_DCONF,
}

TERTIARY_LABEL = "Tertiary/College/University (Default)"

SETUP_CHOICES = [
    "Preschool",
    "Primary/Elementary",
    "Secondary/Middle/High School",
    TERTIARY_LABEL,
]


_NOLOGIN_SHELLS: frozenset[str] = frozenset({
    "/usr/sbin/nologin",
    "/sbin/nologin",
    "/bin/false",
    "/bin/sync",
    "/usr/bin/false",
})


def _adm_members() -> frozenset[str]:
    try:
        return frozenset(grp.getgrnam("adm").gr_mem)
    except KeyError:
        return frozenset()


def list_non_admin_users() -> list[str]:
    """Return sorted usernames with UID >= 1000 not in the adm group,
    excluding service accounts with nologin shells."""
    adm = _adm_members()
    users: list[str] = []
    for pw in pwd.getpwall():
        if (
            pw.pw_uid >= 1000
            and pw.pw_name not in ("nobody", "nfsnobody")
            and pw.pw_name not in adm
            and pw.pw_shell not in _NOLOGIN_SHELLS
        ):
            users.append(pw.pw_name)
    return sorted(users)


def _user_marker_path(username: str) -> Path:
    pw = pwd.getpwnam(username)
    return Path(pw.pw_dir) / ".config" / "edubuntu" / "age-group"


def get_user_default(username: str) -> str:
    """Read the age-group marker file for *username*."""
    try:
        label = _user_marker_path(username).read_text().strip()
        if label in DCONF_CONTENTS:
            return label
    except OSError:
        pass
    return TERTIARY_LABEL


def get_user_defaults(usernames: list[str]) -> dict[str, str]:
    """Return a dict mapping each username to its current age-group label."""
    return {u: get_user_default(u) for u in usernames}


def query_user_defaults(usernames: list[str]) -> dict[str, str]:
    """Read age-group markers via the privileged helper (pkexec).

    This is needed because other users' home directories are typically
    mode 700, so the calling admin user cannot read the marker files
    directly.
    """
    if not usernames:
        return {}
    result: dict[str, str] = {u: TERTIARY_LABEL for u in usernames}
    try:
        proc = subprocess.run(
            ["pkexec", HELPER, "query", *usernames],
            capture_output=True,
            text=True,
        )
        if proc.returncode == 0:
            for line in proc.stdout.splitlines():
                parts = line.split("\t", 1)
                if len(parts) == 2:
                    uname, label = parts[0], parts[1].strip()
                    if uname in result and label in DCONF_CONTENTS:
                        result[uname] = label
    except FileNotFoundError:
        pass
    return result


def apply_user_default(username: str, label: str) -> bool:
    """Apply per-user default setup via pkexec helper."""
    if label == TERTIARY_LABEL:
        try:
            return subprocess.run(
                ["pkexec", HELPER, "usertertdefault", username],
            ).returncode == 0
        except FileNotFoundError:
            return False
    elif label in DCONF_CONTENTS:
        content = DCONF_CONTENTS[label]
        try:
            return subprocess.run(
                ["pkexec", HELPER, "userdefault", username, content, label],
            ).returncode == 0
        except FileNotFoundError:
            return False
    return False


@dataclass
class PackageInfo:
    name: str
    description: str
    installed: bool
    show_depends: bool = True
    show_recommends: bool = True


@dataclass
class MetapackageConfig:
    name: str
    show_depends: bool = True
    show_recommends: bool = True


@dataclass
class PackageRelation:
    relation: str
    package: str


def dpkg_installed(package: str) -> bool:
    """Return True if *package* is currently installed."""
    try:
        result = subprocess.run(
            ["dpkg", "-s", package],
            capture_output=True,
            text=True,
        )
        return result.returncode == 0
    except FileNotFoundError:
        return False


def apt_cache_description(package: str) -> str:
    """Return the short description from apt-cache."""
    try:
        result = subprocess.run(
            ["apt-cache", "search", "^{}$".format(package)],
            capture_output=True,
            text=True,
        )
        for line in result.stdout.strip().splitlines():
            if line.startswith(package):
                parts = line.split(" - ", 1)
                if len(parts) == 2:
                    return parts[1].strip()
        return ""
    except FileNotFoundError:
        return ""


def apt_cache_depends(package: str) -> list[str]:
    """Return direct dependency names of *package*."""
    try:
        result = subprocess.run(
            ["apt-cache", "depends", package],
            capture_output=True,
            text=True,
        )
        deps: list[str] = []
        for line in result.stdout.strip().splitlines():
            if ":" in line:
                dep = line.split(":", 1)[1].strip()
                if dep and dep != "386":
                    deps.append(dep)
        return deps
    except FileNotFoundError:
        return []


def apt_cache_depends_recommends(
    package: str,
    *,
    include_depends: bool = True,
    include_recommends: bool = True,
) -> list[PackageRelation]:
    """Return direct Depends/Recommends relations of *package*."""
    if not include_depends and not include_recommends:
        return []

    try:
        result = subprocess.run(
            ["apt-cache", "depends", package],
            capture_output=True,
            text=True,
        )
    except FileNotFoundError:
        return []

    rows: list[PackageRelation] = []
    seen: set[tuple[str, str]] = set()
    for raw_line in result.stdout.splitlines():
        line = raw_line.strip()
        if not line:
            continue
        if line.startswith("|Depends:") or line.startswith("Depends:"):
            if not include_depends:
                continue
            relation = "Depends"
        elif line.startswith("|Recommends:") or line.startswith("Recommends:"):
            if not include_recommends:
                continue
            relation = "Recommends"
        else:
            continue

        dep = line.split(":", 1)[1].strip()
        if not dep or dep == "386" or dep.startswith("<"):
            continue
        key = (relation, dep)
        if key in seen:
            continue
        seen.add(key)
        rows.append(PackageRelation(relation=relation, package=dep))
    return rows


def _run_streaming(
    cmd: list[str],
    on_output: OutputCallback = None,
) -> bool:
    try:
        proc = subprocess.Popen(
            cmd,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
        )
        assert proc.stdout is not None
        for line in proc.stdout:
            if on_output is not None:
                on_output(line)
        proc.wait()
        return proc.returncode == 0
    except FileNotFoundError:
        return False


def update_apt_cache(on_output: OutputCallback = None) -> bool:
    """Run apt-get update via pkexec."""
    return _run_streaming(["pkexec", HELPER, "update"], on_output)


def load_metapackage_configs() -> list[MetapackageConfig]:
    try:
        raw = json.loads(METAPACKAGE_CONFIG_PATH.read_text())
    except (FileNotFoundError, OSError, json.JSONDecodeError):
        return []

    entries = raw.get("metapackages")
    if not isinstance(entries, list):
        return []

    configs: list[MetapackageConfig] = []
    for entry in entries:
        if not isinstance(entry, dict):
            continue
        name = entry.get("name")
        if not isinstance(name, str) or not name.strip():
            continue
        configs.append(
            MetapackageConfig(
                name=name.strip(),
                show_depends=bool(entry.get("show_depends", True)),
                show_recommends=bool(entry.get("show_recommends", True)),
            )
        )

    return configs


def build_package_table() -> list[PackageInfo]:
    """Return a PackageInfo list for every metapackage."""
    table: list[PackageInfo] = []
    for cfg in load_metapackage_configs():
        installed = dpkg_installed(cfg.name)
        description = apt_cache_description(cfg.name)
        table.append(
            PackageInfo(
                name=cfg.name,
                description=description,
                installed=installed,
                show_depends=cfg.show_depends,
                show_recommends=cfg.show_recommends,
            )
        )
    return table


def install_packages(
    packages: list[str],
    on_output: OutputCallback = None,
    on_progress: ProgressCallback = None,
) -> bool:
    """Install *packages* via pkexec, optionally tracking progress."""
    if not packages:
        return True
    cmd = ["pkexec", HELPER, "install"] + packages

    if on_progress is None:
        return _run_streaming(cmd, on_output)

    total = 0
    done = 0

    def _track(line: str) -> None:
        nonlocal total, done
        if on_output is not None:
            on_output(line)
        m = _APT_SUMMARY_RE.search(line)
        if m:
            total = int(m.group(1)) + int(m.group(2))  # upgraded + newly installed
        if total > 0 and line.startswith("Setting up "):
            done += 1
            on_progress(min(done / total, 1.0))

    return _run_streaming(cmd, _track)


def autoremove_packages(
    packages: list[str],
    on_output: OutputCallback = None,
    on_progress: ProgressCallback = None,
) -> bool:
    """Auto-mark deps and autoremove *packages* via pkexec."""
    if not packages:
        return True
    cmd = ["pkexec", HELPER, "autoremove"] + packages

    if on_progress is None:
        return _run_streaming(cmd, on_output)

    total = 0
    done = 0

    def _track(line: str) -> None:
        nonlocal total, done
        if on_output is not None:
            on_output(line)
        m = _APT_SUMMARY_RE.search(line)
        if m:
            total = int(m.group(3))  # "to remove"
        if total > 0 and line.startswith("Removing "):
            done += 1
            on_progress(min(done / total, 1.0))

    return _run_streaming(cmd, _track)


def is_gnome_session() -> bool:
    """Return True if running under an Ubuntu/GNOME session."""
    desktop = os.environ.get("DESKTOP_SESSION", "").lower()
    return desktop == "ubuntu"


def get_current_default() -> str:
    """Detect which age-group default is currently active."""
    for label, path in GSCHEMA_OVERRIDES.items():
        if path.exists():
            return label
    return "Tertiary/College/University (Default)"



def apply_default(label: str) -> bool:
    """Apply the chosen GNOME default-setup via pkexec."""
    if label in GSCHEMA_CONTENTS:
        content = GSCHEMA_CONTENTS[label]
        target = str(GSCHEMA_OVERRIDES[label])
        try:
            result = subprocess.run(
                ["pkexec", HELPER, "newdefault", target, content],
            )
            return result.returncode == 0
        except FileNotFoundError:
            return False
    else:
        # Tertiary — remove all overrides
        try:
            result = subprocess.run(
                ["pkexec", HELPER, "tertdefault"],
            )
            return result.returncode == 0
        except FileNotFoundError:
            return False
