#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Pagan-Welcome – logika zaplecza i CLI dla interfejsu GTK.

Moduł dostarcza czyste funkcje do pobierania i parsowania nowości oraz
repozytorium PAG, odczytu stanu zainstalowanych pakietów i ustawień systemu.
Uruchomiony z argumentem działa też jako CLI dla aplikacji GTK
(`pagan-welcome`), wypisując dane w prostym formacie TSV:

    pagan-welcome-backend.py news <pl|en>
    pagan-welcome-backend.py repo
    pagan-welcome-backend.py installed
    pagan-welcome-backend.py updates
    pagan-welcome-backend.py sysinfo
    pagan-welcome-backend.py timezones

Dzięki temu ciężkie parsowanie (HTML, JSON) zostaje w Pythonie, a warstwa
GTK w C zajmuje się wyłącznie prezentacją.
"""

import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
from html.parser import HTMLParser

# ── Adresy i ścieżki ──────────────────────────────────────────────────────
DOCS_URL = "https://docs.paganlinux.eu/docs/paganlinux"
REPO_JSON_URL = "https://repo.paganlinux.eu/stable/repo.json"
USER_AGENT = "Pagan-Welcome/1.0 (+https://paganlinux.eu)"
HTTP_TIMEOUT = 20

HOME = os.path.expanduser("~")
CONFIG_DIR = os.path.join(HOME, ".config", "pagan-welcome")
CACHE_DIR = os.path.join(HOME, ".cache", "pagan-welcome")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")

PAG_DB = "/var/lib/pag/installed.json"
PAG_WORLD = "/var/lib/pag/world"
PAG_BIN = "/usr/bin/pag"
CALAMARES_BIN = "/usr/bin/calamares"

# Nazwa pakietu: bezpieczna, bez spacji i znaków powłoki (walidacja przed
# przekazaniem do `pag`/`pkexec`).
PKG_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+@-]*$")


# ── Konfiguracja użytkownika ──────────────────────────────────────────────
def load_config():
    """Wczytaj konfigurację (~/.config/pagan-welcome/config.json)."""
    try:
        with open(CONFIG_FILE, encoding="utf-8") as fh:
            data = json.load(fh)
        if isinstance(data, dict):
            return data
    except (OSError, ValueError):
        pass
    return {}


def save_config(cfg):
    """Zapisz konfigurację; błędy zapisu nie przerywają działania aplikacji."""
    try:
        os.makedirs(CONFIG_DIR, exist_ok=True)
        tmp = CONFIG_FILE + ".new"
        with open(tmp, "w", encoding="utf-8") as fh:
            json.dump(cfg, fh, indent=2, ensure_ascii=False)
        os.replace(tmp, CONFIG_FILE)
        return True
    except OSError:
        return False


def detect_lang(cfg=None):
    """Ustal język: konfiguracja → $LANG → polski."""
    cfg = cfg if cfg is not None else load_config()
    lang = (cfg.get("lang") or "").strip().lower()
    if lang in ("pl", "en"):
        return lang
    env = os.environ.get("LC_ALL") or os.environ.get("LC_MESSAGES") or os.environ.get("LANG") or ""
    if env.lower().startswith("pl"):
        return "pl"
    if env.lower().startswith("en"):
        return "en"
    return "pl"


# ── Wykrywanie środowiska ─────────────────────────────────────────────────
def is_root():
    return hasattr(os, "geteuid") and os.geteuid() == 0


def have(cmd):
    """Czy polecenie jest dostępne w PATH (albo jako ścieżka absolutna)."""
    if os.path.isabs(cmd):
        return os.path.exists(cmd)
    return shutil.which(cmd) is not None


def is_live():
    """Czy działamy w sesji live (ISO/pendrive), a nie z zainstalowanego dysku.

    Trzy niezależne sygnały: parametr jądra `pagan.live`/`live`, katalogi
    montowania systemu live oraz root na overlayu (tak montuje go initramfs).
    """
    try:
        with open("/proc/cmdline", encoding="utf-8", errors="replace") as fh:
            cmdline = fh.read()
    except OSError:
        cmdline = ""
    if "pagan.live" in cmdline or re.search(r"(?:^|\s)live(?:\s|$)", cmdline):
        return True
    for marker in ("/run/live", "/lib/live/mount", "/live/root"):
        if os.path.isdir(marker):
            return True
    try:
        with open("/proc/mounts", encoding="utf-8", errors="replace") as fh:
            for line in fh:
                parts = line.split()
                if len(parts) >= 3 and parts[1] == "/" and parts[2] == "overlay":
                    return True
    except OSError:
        pass
    return False


def format_size(num):
    """Rozmiar w bajtach → czytelny zapis (np. 1,2 MB)."""
    try:
        num = float(num)
    except (TypeError, ValueError):
        return "?"
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if num < 1024 or unit == "TB":
            if unit == "B":
                return "%d %s" % (int(num), unit)
            return "%.1f %s" % (num, unit)
        num /= 1024.0
    return "?"


# ── HTTP ──────────────────────────────────────────────────────────────────
# Ponowienia dla BŁĘDÓW PRZEJŚCIOWYCH. Powód: w świeżo zalogowanej sesji LIVE
# NetworkManager potrafi podnieść interfejs DOPIERO po starcie pagan-welcome
# (autostart sesji), więc pierwsze zapytanie kończy się „Network is unreachable”
# i okno pokazywało puste „Nowości”, mimo że chwilę później sieć już działa.
HTTP_RETRIES = 3          # łącznie próby (pierwsza + ponowienia)
HTTP_RETRY_DELAY = 3.0    # sekundy przerwy między próbami


def http_get(url, timeout=HTTP_TIMEOUT, retries=HTTP_RETRIES):
    """Pobierz URL i zwróć treść jako tekst (UTF-8).

    Błąd HTTP (4xx/5xx) NIE jest ponawiany – to nie usterka sieci. Ponawiamy
    tylko błędy przejściowe (brak trasy, DNS, przekroczony czas). Rzuca OSError.
    """
    attempts = max(1, int(retries))
    last = None
    for attempt in range(attempts):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
            with urllib.request.urlopen(req, timeout=timeout) as resp:
                raw = resp.read()
            return raw.decode("utf-8", errors="replace")
        except urllib.error.HTTPError:
            raise
        except (urllib.error.URLError, OSError) as exc:
            last = exc
            if attempt + 1 < attempts:
                time.sleep(HTTP_RETRY_DELAY)
    raise last


# ── Nowości (docs.paganlinux.eu) ──────────────────────────────────────────
class _MarkdownBodyParser(HTMLParser):
    """Wyciąga bloki (akapit, nagłówek, cytat, lista) z <div class="markdown-body">.

    Strona docs renderuje treść z Markdownu do HTML, więc zamiast zgadywać
    strukturę, zbieramy kolejne bloki i grupujemy je w wydania.
    """

    _BLOCK_TAGS = ("p", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "li")

    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.in_body = False
        self.body_depth = 0
        self.blocks = []
        self._cur = None
        self._buf = []

    def handle_starttag(self, tag, attrs):
        if not self.in_body:
            if tag == "div" and "markdown-body" in (dict(attrs).get("class") or ""):
                self.in_body = True
                self.body_depth = 1
            return
        if tag == "div":
            self.body_depth += 1
        elif tag == "br":
            self._buf.append("\n")
        elif tag in self._BLOCK_TAGS and self._cur is None:
            self._cur = tag
            self._buf = []

    def handle_endtag(self, tag):
        if not self.in_body:
            return
        if tag == "div":
            self.body_depth -= 1
            if self.body_depth <= 0:
                self._flush()
                self.in_body = False
            return
        if tag in self._BLOCK_TAGS and self._cur == tag:
            self._flush()

    def handle_data(self, data):
        if self.in_body and self._cur is not None:
            self._buf.append(data)

    def _flush(self):
        text = "".join(self._buf).strip()
        if text:
            self.blocks.append({"tag": self._cur, "text": text})
        self._cur = None
        self._buf = []


_RELEASE_RE = re.compile(
    r"^\s*(?:wydanie|wersja|release|version)\b", re.IGNORECASE)
_VERSION_RE = re.compile(r"\b\d+\.\d+(?:\.\d+)?\b")


def _looks_like_release(text):
    """Czy akapit/nagłówek rozpoczyna nowe wydanie?"""
    if _RELEASE_RE.match(text):
        return True
    # Krótki nagłówek z numerem wersji, np. "0.1.2 Mokosza".
    if len(text) <= 60 and _VERSION_RE.search(text):
        return True
    return False


def parse_news_html(html):
    """Zamień HTML strony docs na strukturę {title, releases:[{title, notes}]}."""
    title = ""
    m = re.search(r'<h1[^>]*class="[^"]*dv-title[^"]*"[^>]*>(.*?)</h1>', html, re.S)
    if m:
        title = re.sub(r"<[^>]+>", "", m.group(1)).strip()
    if not title:
        m = re.search(r"<title>(.*?)</title>", html, re.S)
        if m:
            title = re.sub(r"\s*[–|-]\s*PaganOS.*$", "", m.group(1)).strip()

    parser = _MarkdownBodyParser()
    try:
        parser.feed(html)
    except Exception:
        pass

    releases = []
    current = None
    for block in parser.blocks:
        text = block["text"]
        if block["tag"] in ("p", "h1", "h2", "h3", "h4", "h5", "h6") and _looks_like_release(text):
            current = {"title": text, "notes": []}
            releases.append(current)
            continue
        if current is None:
            current = {"title": title or "", "notes": []}
            releases.append(current)
        # Notatki: rozbij wielolinijkowe cytaty na osobne punkty.
        for line in text.splitlines():
            line = line.strip()
            if line:
                current["notes"].append(line)

    # Usuń puste wydania (bez tytułu i notatek).
    releases = [r for r in releases if r["title"] or r["notes"]]
    return {"title": title, "releases": releases}


def _cache_path(name):
    return os.path.join(CACHE_DIR, name)


def _write_cache(name, payload):
    try:
        os.makedirs(CACHE_DIR, exist_ok=True)
        tmp = _cache_path(name) + ".new"
        with open(tmp, "w", encoding="utf-8") as fh:
            json.dump(payload, fh, ensure_ascii=False)
        os.replace(tmp, _cache_path(name))
    except OSError:
        pass


def _read_cache(name):
    try:
        with open(_cache_path(name), encoding="utf-8") as fh:
            return json.load(fh)
    except (OSError, ValueError):
        return None


def fetch_news(lang="pl", force=False):
    """Pobierz nowości dla języka `lang`.

    Zwraca dict: {title, releases, source, updated, cached, error}.
    Przy braku sieci zwraca ostatnią zapamiętaną wersję (jeśli jest).
    """
    cache_name = "news-%s.json" % lang
    url = "%s?lang=%s" % (DOCS_URL, lang)
    result = {
        "title": "",
        "releases": [],
        "source": url,
        "updated": None,
        "cached": False,
        "error": None,
    }

    if not force:
        cached = _read_cache(cache_name)
        if cached:
            result.update(cached)
            result["cached"] = True
            return result

    try:
        html = http_get(url)
        parsed = parse_news_html(html)
        result["title"] = parsed["title"]
        result["releases"] = parsed["releases"]
        result["updated"] = time.strftime("%Y-%m-%d %H:%M")
        result["cached"] = False
        _write_cache(cache_name, {
            "title": result["title"],
            "releases": result["releases"],
            "updated": result["updated"],
        })
        return result
    except (OSError, ValueError) as exc:
        cached = _read_cache(cache_name)
        if cached:
            result.update(cached)
            result["cached"] = True
            result["error"] = str(exc)
            return result
        result["error"] = str(exc)
        return result


# ── Repozytorium PAG ──────────────────────────────────────────────────────
def fetch_repo(force=False):
    """Pobierz indeks repozytorium (repo.json).

    Zwraca dict: {packages, updated, cached, error}. `packages` to lista
    słowników z polami name/version/release/size/description/depends/license.
    """
    result = {"packages": [], "updated": None, "cached": False, "error": None}

    if not force:
        cached = _read_cache("repo.json")
        if cached:
            result.update(cached)
            result["cached"] = True
            return result

    try:
        raw = http_get(REPO_JSON_URL)
        data = json.loads(raw)
        packages = data.get("packages") or []
        if not isinstance(packages, list):
            packages = []
        result["packages"] = packages
        result["updated"] = data.get("updated") or time.strftime("%Y-%m-%d %H:%M")
        result["cached"] = False
        _write_cache("repo.json", {
            "packages": packages,
            "updated": result["updated"],
        })
        return result
    except (OSError, ValueError) as exc:
        cached = _read_cache("repo.json")
        if cached:
            result.update(cached)
            result["cached"] = True
            result["error"] = str(exc)
            return result
        result["error"] = str(exc)
        return result


def installed_packages():
    """Zwróć mapę nazwa → wpis z bazy pag (/var/lib/pag/installed.json)."""
    try:
        with open(PAG_DB, encoding="utf-8") as fh:
            data = json.load(fh)
        if isinstance(data, dict):
            return data
    except (OSError, ValueError):
        pass
    return {}


def world_packages():
    """Pakiety zainstalowane świadomie (/var/lib/pag/world) – zbiór nazw."""
    try:
        with open(PAG_WORLD, encoding="utf-8") as fh:
            return {line.strip() for line in fh if line.strip()}
    except OSError:
        return set()


def valid_package_name(name):
    return bool(name) and bool(PKG_NAME_RE.match(name))


# ── Uruchamianie poleceń ──────────────────────────────────────────────────
def run_stream(cmd, on_line=None, env=None, input_text=None, cwd=None):
    """Uruchom polecenie, strumieniując wyjście linia po linii.

    `on_line(line)` jest wołane dla każdej linii (stdout+stderr razem).
    `input_text` trafia na stdin (np. hasło sudo). Zwraca kod wyjścia.
    """
    full_env = dict(os.environ)
    if env:
        full_env.update(env)
    try:
        proc = subprocess.Popen(
            cmd, cwd=cwd, env=full_env,
            stdin=subprocess.PIPE if input_text is not None else subprocess.DEVNULL,
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
            text=True, errors="replace", bufsize=1,
        )
    except (OSError, ValueError) as exc:
        if on_line:
            on_line("! %s" % exc)
        return 127

    if input_text is not None:
        try:
            proc.stdin.write(input_text)
            proc.stdin.flush()
        except (OSError, ValueError):
            pass
        finally:
            try:
                proc.stdin.close()
            except (OSError, ValueError):
                pass

    try:
        for line in proc.stdout:
            if on_line:
                on_line(line.rstrip("\n"))
    except (OSError, ValueError):
        pass
    finally:
        try:
            proc.stdout.close()
        except (OSError, ValueError):
            pass
    proc.wait()
    return proc.returncode


def _polkit_agent_running():
    """Czy w sesji działa agent polkit (wtedy pkexec ma kto pokazać okno)?."""
    for name in ("polkit-gnome-authentication-agent-1", "xfce-polkit",
                 "polkit-kde-authentication-agent-1", "mate-polkit",
                 "lxpolkit", "polkit-mate-authentication-agent-1"):
        try:
            if subprocess.run(["pgrep", "-x", name],
                              stdout=subprocess.DEVNULL,
                              stderr=subprocess.DEVNULL).returncode == 0:
                return True
        except OSError:
            break
    return False


def privileged_prefix():
    """Prefiks polecenia z uprawnieniami root.

    - root → brak prefiksu,
    - pkexec (gdy działa agent polkit) → graficzne okno autoryzacji,
    - w przeciwnym razie sudo -S (hasło podaje wywołujący).
    """
    if is_root():
        return []
    if have("pkexec") and _polkit_agent_running():
        return ["pkexec"]
    if have("sudo"):
        return ["sudo", "-S"]
    return []


def run_privileged(cmd, on_line=None, env=None, password=None):
    """Uruchom polecenie z uprawnieniami root.

    `password` jest używane tylko wtedy, gdy prefiks to sudo -S.
    Zwraca kod wyjścia (127, gdy nie ma jak podnieść uprawnień).
    """
    prefix = privileged_prefix()
    if not prefix and not is_root():
        if on_line:
            on_line("! Brak pkexec/sudo – nie mogę podnieść uprawnień.")
        return 127

    full = list(prefix)
    if env:
        full.append("env")
        full.extend("%s=%s" % (k, v) for k, v in env.items())
    full.extend(cmd)

    input_text = None
    if prefix[:1] == ["sudo"]:
        input_text = (password or "") + "\n"
    return run_stream(full, on_line=on_line, input_text=input_text)


def pag_available():
    return have(PAG_BIN) or have("pag")


def pag_cmd():
    return PAG_BIN if have(PAG_BIN) else "pag"


# ── Porównywanie wersji i liczenie aktualizacji ───────────────────────────
def version_key(ver):
    """Klucz sortowania wersji: segmenty liczbowe porównywane liczbowo."""
    key = []
    for part in re.split(r"[._+~-]", str(ver or "")):
        if part.isdigit():
            key.append((0, int(part), ""))
        else:
            key.append((1, 0, part))
    return key


def count_updates(installed, repo_packages):
    """Ile zainstalowanych pakietów ma w repo nowszą wersję."""
    repo = {p.get("name"): p for p in repo_packages if p.get("name")}
    count = 0
    for name, entry in (installed or {}).items():
        rp = repo.get(name)
        if not rp:
            continue
        iv = str(entry.get("version", ""))
        rv = str(rp.get("version", ""))
        if iv and rv and version_key(rv) > version_key(iv):
            count += 1
    return count


# ── Bieżące ustawienia systemu (odczyt) ───────────────────────────────────
def _run_capture(cmd, timeout=6):
    try:
        out = subprocess.run(cmd, stdout=subprocess.PIPE,
                             stderr=subprocess.DEVNULL, text=True,
                             errors="replace", timeout=timeout)
        return out.stdout.strip()
    except (OSError, subprocess.SubprocessError):
        return ""


def current_locale():
    """Kod języka z lokalizacji systemowej (np. 'pl' z 'pl_PL.UTF-8')."""
    status = _run_capture(["localectl", "status"])
    m = re.search(r"LANG=([A-Za-z_]+)", status)
    if m:
        return m.group(1).split("_")[0].lower()
    return ""


def current_keymap():
    """Układ klawiatury X11 (np. 'pl')."""
    status = _run_capture(["localectl", "status"])
    m = re.search(r"X11 Layout:\s*(\S+)", status)
    if m:
        return m.group(1)
    return _run_capture(["localectl", "status"])


def current_timezone():
    tz = _run_capture(["timedatectl", "show", "-p", "Timezone", "--value"])
    if tz:
        return tz
    try:
        with open("/etc/timezone", encoding="utf-8") as fh:
            return fh.read().strip()
    except OSError:
        return ""


def current_hostname():
    name = _run_capture(["hostname"])
    if name:
        return name
    try:
        with open("/etc/hostname", encoding="utf-8") as fh:
            return fh.read().strip()
    except OSError:
        return ""


def available_timezones():
    """Lista stref czasowych z /usr/share/zoneinfo/zone.tab (posortowana)."""
    zones = []
    try:
        with open("/usr/share/zoneinfo/zone.tab", encoding="utf-8") as fh:
            for line in fh:
                if line.startswith("#") or not line.strip():
                    continue
                parts = line.split("\t")
                if len(parts) >= 3:
                    zones.append(parts[2].strip())
    except OSError:
        pass
    if not zones:
        zones = ["Europe/Warsaw", "Europe/London", "Europe/Berlin",
                 "America/New_York", "UTC"]
    return sorted(set(zones))


# ── CLI (TSV) dla interfejsu GTK ──────────────────────────────────────────
def _field(value):
    """Pole TSV: bez tabulatorów i znaków nowej linii."""
    text = "" if value is None else str(value)
    return text.replace("\t", " ").replace("\r", " ").replace("\n", " ")


def _emit(*cols):
    print("\t".join(_field(c) for c in cols))


def _cmd_news(args):
    lang = args[0] if args else "pl"
    data = fetch_news(lang, force=True)
    _emit("UPDATED", data.get("updated") or "")
    _emit("CACHED", "1" if data.get("cached") else "0")
    _emit("ERROR", data.get("error") or "")
    _emit("TITLE", data.get("title") or "")
    for rel in data.get("releases") or []:
        _emit("REL", rel.get("title") or "")
        for note in rel.get("notes") or []:
            _emit("NOTE", note)
    return 0


def _cmd_repo(_args):
    data = fetch_repo(force=True)
    _emit("UPDATED", data.get("updated") or "")
    _emit("CACHED", "1" if data.get("cached") else "0")
    _emit("ERROR", data.get("error") or "")
    for pkg in data.get("packages") or []:
        deps = ",".join(pkg.get("depends") or [])
        lic = ",".join(pkg.get("license") or [])
        _emit("PKG", pkg.get("name") or "", pkg.get("version") or "",
              pkg.get("size") or 0, pkg.get("description") or "", deps, lic)
    return 0


def _cmd_installed(_args):
    for name, entry in sorted(installed_packages().items()):
        _emit("INST", name, entry.get("version") or "")
    return 0


def _cmd_updates(_args):
    installed = installed_packages()
    repo = fetch_repo()
    count = count_updates(installed, repo.get("packages") or [])
    _emit("UPDATES", count)
    return 0


def _cmd_sysinfo(_args):
    _emit("LOCALE", current_locale())
    _emit("KEYMAP", current_keymap())
    _emit("TIMEZONE", current_timezone())
    _emit("HOSTNAME", current_hostname())
    _emit("LIVE", "1" if is_live() else "0")
    _emit("CALAMARES", "1" if have(CALAMARES_BIN) else "0")
    _emit("PAG", "1" if pag_available() else "0")
    return 0


def _cmd_timezones(_args):
    for zone in available_timezones():
        _emit("TZ", zone)
    return 0


def main(argv):
    if not argv or argv[0] in ("-h", "--help", "help"):
        print("Użycie: pagan-welcome-backend.py {news <pl|en>|repo|installed|"
              "updates|sysinfo|timezones}")
        return 0 if argv else 2
    cmd = argv[0]
    args = argv[1:]
    table = {
        "news": _cmd_news,
        "repo": _cmd_repo,
        "installed": _cmd_installed,
        "updates": _cmd_updates,
        "sysinfo": _cmd_sysinfo,
        "timezones": _cmd_timezones,
    }
    handler = table.get(cmd)
    if handler is None:
        print("Nieznane polecenie: %s" % cmd, file=sys.stderr)
        return 2
    try:
        return handler(args)
    except (OSError, ValueError) as exc:
        print("Błąd: %s" % exc, file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))