#!/usr/bin/python3
import fcntl
import grp
import os
import pwd
import pty
import re
import shlex
import struct
import subprocess
import termios
import threading

import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GLib, Pango

# «Установка : пакет [===   ] 42/52» — строка транзакции: консольная полоса
# показывает прогресс установки текущего пакета (у больших — заполняется долго),
# а 42/52 — счётчик пакетов, он остаётся в статус-строке и в логе
TXN_BAR_RE = re.compile(r"\[([= ]+)\]\s*\d+\s*/\s*\d+")
# Консольная полоса [====] — из статус-строки убираем, прогресс показан графически
BAR_RE = re.compile(r"\[[^]\r\n]*\]")
# ANSI-последовательности (цвет, жирный, смена кодировки) — в графическом логе не нужны
ANSI_RE = re.compile(r"\x1b\[[0-9;:?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-Za-z]")
# 45% [==== ] или [==== ] 45% — прогресс скачивания пакета
PERCENT_BAR_RE = re.compile(r"(?:\[[^]\r\n]*\]\s*(\d+)\s*%|(\d+)\s*%\s*\[[^]\r\n]*\])")

# Dummy-пакет, закрывающий зависимость libc.so.6(GLIBC_2.34) пакета
# регистрации kactivation (собирается нашим spec-файлом)
DUMMY_PKG = "libc-so-6-GLIBC-2-34-64bit"


class InstallKompasGUI:
    def __init__(self):
        self.window = Gtk.Window(title="Установка КОМПАС-3D v25")
        self.window.set_icon_name("install")
        self.window.set_border_width(10)
        self.window.set_default_size(600, 500)
        self.window.connect("destroy", Gtk.main_quit)
        self.window.connect("delete-event", self.on_delete_event)
        self.action_running = False

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        self.window.add(vbox)

        # Репозитории
        repo_label = Gtk.Label(label="Репозитории:", halign=Gtk.Align.START)
        vbox.pack_start(repo_label, False, False, 0)

        self.repo_entry = Gtk.Entry()
        self.repo_entry.set_text("https://repo.ascon.ru/stable/rpm/redos/8.0/")
        vbox.pack_start(self.repo_entry, False, False, 0)

        self.repo_entry1 = Gtk.Entry()
        self.repo_entry1.set_text("https://repo.ascon.ru/personal/rpm/redos/8.0/")
        self.repo_entry1.set_placeholder_text("Дополнительный репозиторий (очистите, чтобы не использовать)")
        vbox.pack_start(self.repo_entry1, False, False, 0)

        self.repo_entry2 = Gtk.Entry()
        self.repo_entry2.set_placeholder_text("Дополнительный репозиторий (необязательно)")
        vbox.pack_start(self.repo_entry2, False, False, 0)

        # Что устанавливать
        install_label = Gtk.Label(label="Что устанавливать:", halign=Gtk.Align.START)
        vbox.pack_start(install_label, False, False, 10)

        self.install_combo = Gtk.ComboBoxText()
        self.install_combo.append("all", "КОМПАС-3D и Просмотрщик")
        self.install_combo.append("kompas", "Только КОМПАС-3D")
        self.install_combo.append("viewer", "Только Просмотрщик")
        self.install_combo.append("home", "Домашняя версия")
        self.install_combo.append("study", "Учебная версия")
        self.install_combo.set_active(0)
        vbox.pack_start(self.install_combo, False, False, 0)

        # Кнопки
        btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)

        self.install_btn = Gtk.Button(label="Установить")
        self.install_btn.connect("clicked", self.on_install_clicked)
        btn_box.pack_start(self.install_btn, False, False, 0)

        self.update_btn = Gtk.Button(label="Обновить")
        self.update_btn.connect("clicked", self.on_update_clicked)
        btn_box.pack_start(self.update_btn, False, False, 0)

        self.remove_btn = Gtk.Button(label="Удалить")
        self.remove_btn.connect("clicked", self.on_remove_clicked)
        btn_box.pack_start(self.remove_btn, False, False, 0)

        self.purge_btn = Gtk.Button(label="Удалить и очистить")
        self.purge_btn.connect("clicked", self.on_purge_clicked)
        btn_box.pack_start(self.purge_btn, False, False, 0)

        vbox.pack_start(btn_box, False, False, 20)

        # Прогресс
        self.progress = Gtk.ProgressBar(show_text=True)
        self.progress.set_text("Готово к установке")
        vbox.pack_start(self.progress, False, False, 0)

        # Текущая операция dnf (скачивание/установка пакета), как в консоли
        self.status_label = Gtk.Label(label="", halign=Gtk.Align.START, xalign=0)
        self.status_label.set_ellipsize(Pango.EllipsizeMode.END)
        self.status_label.get_style_context().add_class("monospace")
        vbox.pack_start(self.status_label, False, False, 0)

        # Лог
        log_label = Gtk.Label(label="Лог:", halign=Gtk.Align.START)
        vbox.pack_start(log_label, False, False, 10)

        self.log_view = Gtk.TextView()
        self.log_view.set_editable(False)
        self.log_view.set_monospace(True)
        css_provider = Gtk.CssProvider()
        css_provider.load_from_data(b"textview { font-family: monospace; font-size: 10pt; }")
        self.log_view.get_style_context().add_provider(css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        self.log_buffer = self.log_view.get_buffer()

        self.scrolled = Gtk.ScrolledWindow()
        self.scrolled.set_vexpand(True)
        self.scrolled.add(self.log_view)
        vbox.pack_start(self.scrolled, True, True, 0)

        self.window.show_all()
        # Убрать автоматическое выделение текста в поле адреса
        GLib.idle_add(self.repo_entry.set_position, -1)

        self.detect_installed()

    def detect_installed(self):
        self.kompas_installed = subprocess.run(
            ["rpm", "-q", "ascon-kompas3d-v25"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
        ).returncode == 0
        self.viewer_installed = subprocess.run(
            ["rpm", "-q", "ascon-kompas3d-viewer-v25"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
        ).returncode == 0
        self.home_installed = subprocess.run(
            ["rpm", "-q", "ascon-kompas3d-home-v25-full"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
        ).returncode == 0
        self.study_installed = subprocess.run(
            ["rpm", "-q", "ascon-kompas3d-study-v25-full"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
        ).returncode == 0
        installed = self.kompas_installed or self.viewer_installed or self.home_installed or self.study_installed
        self.update_btn.set_visible(installed)
        self.remove_btn.set_visible(installed)
        self.purge_btn.set_visible(self.detect_purgeable())

        # Обновляем список вариантов установки с учётом уже установленного
        self.install_combo.remove_all()
        if not self.kompas_installed and not self.viewer_installed and not self.home_installed and not self.study_installed:
            self.install_combo.append("all", "КОМПАС-3D и Просмотрщик")
            self.install_combo.append("kompas", "Только КОМПАС-3D")
            self.install_combo.append("viewer", "Только Просмотрщик")
            self.install_combo.append("home", "Домашняя версия")
            self.install_combo.append("study", "Учебная версия")
            self.install_combo.set_active(0)
        elif self.kompas_installed and not self.viewer_installed:
            self.install_combo.append("viewer", "Просмотрщик")
            self.install_combo.set_active(0)
        elif not self.kompas_installed and self.viewer_installed:
            self.install_combo.append("kompas", "КОМПАС-3D")
            self.install_combo.set_active(0)
        # Если оба установлены — список пуст, устанавливать нечего
        has_install_options = self.install_combo.get_model().iter_n_children(None) > 0
        self.install_btn.set_visible(has_install_options)

    def detect_purgeable(self):
        """Есть ли что очищать: установленные пакеты ascon или хвосты от них
        (каталоги, пользователь/группы) — пакеты могли быть удалены ранее."""
        # любой ascon-пакет, dummy-пакет или guardant
        for pattern in ("ascon*", DUMMY_PKG, "grdcontrol"):
            if subprocess.run(["rpm", "-q", pattern],
                              stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
                              ).returncode == 0:
                return True
        # хвосты в файловой системе (список синхронизирован с on_purge_clicked)
        for path in ("/opt/ascon", "/opt/ascon-tutorial-viewer", "/opt/guardant",
                     "/etc/ascon", "/var/lib/ascon", "/var/log/ascon",
                     "/home/polynom", "/etc/profile.d/ascon-kompas-sdk.sh",
                     "/usr/bin/ascon-tutorial-viewer"):
            if os.path.exists(path):
                return True
        # пользователь и группы, созданные пакетами
        try:
            pwd.getpwnam("polynom")
            return True
        except KeyError:
            pass
        for group in ("ascon", "ascon-kompas"):
            try:
                grp.getgrnam(group)
                return True
            except KeyError:
                continue
        return False

    def get_repos(self):
        # Все три переменные передаются всегда, включая пустые: пустое значение
        # означает «репозиторий не использовать» (скрипты отличают пустое от незаданного)
        return [(name, entry.get_text().strip())
                for name, entry in (("KOMPAS_REPO_URL", self.repo_entry),
                                    ("KOMPAS_REPO_URL1", self.repo_entry1),
                                    ("KOMPAS_REPO_URL2", self.repo_entry2))]

    def set_buttons_sensitive(self, sensitive):
        self.install_btn.set_sensitive(sensitive)
        self.update_btn.set_sensitive(sensitive)
        self.remove_btn.set_sensitive(sensitive)
        self.purge_btn.set_sensitive(sensitive)

    def log(self, text):
        end_iter = self.log_buffer.get_end_iter()
        self.log_buffer.insert(end_iter, text + "\n")
        adj = self.scrolled.get_vadjustment()
        adj.set_value(adj.get_upper())

    @staticmethod
    def parse_progress(text):
        """Достаёт долю прогресса из строки dnf; None, если строка не о прогрессе."""
        m = PERCENT_BAR_RE.search(text)
        if m:
            return int(m.group(1) or m.group(2)) / 100
        # Строка транзакции: доля — заполненность консольной полосы
        # (прогресс текущего пакета), а не счётчик пакетов N/M
        m = TXN_BAR_RE.search(text)
        if m:
            fill = m.group(1)
            return fill.count("=") / len(fill)
        return None

    def feed(self, text, transient):
        """Обновляет статус-строку и полосу прогресса по выводу dnf.

        transient=True — мимолётная перерисовка (\\r), в лог не попадает.
        """
        fraction = self.parse_progress(text)
        # Убираем консольную полосу [====] и лишние отступы dnf —
        # прогресс уже показан полосой выше
        text = " ".join(BAR_RE.sub(" ", text).split())
        if not text:
            return False
        if fraction is not None:
            self.status_label.set_text(text)
            self.progress.set_fraction(min(max(fraction, 0.0), 1.0))
        elif not transient:
            self.status_label.set_text(text)
        return False

    def start_action(self, title, argv, pre_log=()):
        # pkexec сбрасывает окружение — задаём русскую локаль, чтобы
        # вывод dnf был на русском, как и весь интерфейс
        if argv[1:3] == ["sh", "-c"]:
            argv = ["pkexec", "env", "LANG=ru_RU.UTF-8", "LC_ALL=ru_RU.UTF-8"] + argv[1:]
        self.set_buttons_sensitive(False)
        self.action_running = True
        self.progress.set_fraction(0)
        self.progress.set_text(title + "...")
        # Лог прошлой операции не затираем — отделяем разделителем
        if self.log_buffer.get_char_count() > 0:
            self.log("")
            self.log("=" * 60)
        for line in pre_log:
            self.log(line)
        self.log("")
        self.status_label.set_text("")

        def decode(seg):
            return ANSI_RE.sub("", seg.decode("utf-8", "replace"))

        def read_output(master):
            # dnf рисует прогресс только в терминале, поэтому вывод идёт через pty.
            # \r-перерисовки — прогресс (только в статус), \n — завершённые строки (в лог).
            buf = b""
            while True:
                try:
                    data = os.read(master, 65536)
                except OSError:  # EIO — дочерний процесс завершился
                    break
                if not data:
                    break
                buf += data
                while True:
                    nl = buf.find(b"\n")
                    cr = buf.find(b"\r")
                    if nl == -1 and cr == -1:
                        break
                    if cr != -1 and (nl == -1 or cr < nl):
                        seg, buf = buf[:cr], buf[cr + 1:]
                        if buf.startswith(b"\n"):
                            buf = buf[1:]
                            text = decode(seg)
                            GLib.idle_add(self.log, text)
                            GLib.idle_add(self.feed, text, False)
                        else:
                            GLib.idle_add(self.feed, decode(seg), True)
                    else:
                        seg, buf = buf[:nl], buf[nl + 1:]
                        text = decode(seg)
                        GLib.idle_add(self.log, text)
                        GLib.idle_add(self.feed, text, False)
            if buf.strip():
                GLib.idle_add(self.log, decode(buf))
            os.close(master)

        def run():
            master = None
            try:
                master, slave = pty.openpty()
                # Ширина "терминала" как в обычной консоли — dnf строит компактные
                # таблицы транзакции и не заворачивает строки прогресса
                fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", 24, 120, 0, 0))
                try:
                    proc = subprocess.Popen(
                        argv,
                        stdout=slave,
                        stderr=slave,
                        stdin=subprocess.DEVNULL
                    )
                finally:
                    os.close(slave)
                read_output(master)
                proc.wait()
                GLib.idle_add(self.action_finished, title, proc.returncode)
            except Exception as e:
                if master is not None:
                    try:
                        os.close(master)
                    except OSError:
                        pass
                GLib.idle_add(self.action_finished, title, 1, str(e))

        threading.Thread(target=run, daemon=True).start()

    def on_install_clicked(self, button):
        repos = self.get_repos()
        install_type = self.install_combo.get_active_id()

        pre_log = [f"{name}: {url if url else '— не используется'}" for name, url in repos]
        pre_log.append(f"Устанавливается: {install_type}")

        env = " ".join(f"{name}={shlex.quote(url)}" for name, url in repos)
        cmd = f"{env} KOMPAS_INSTALL={shlex.quote(install_type)} install-kompas-v25"

        self.start_action("Установка", ["pkexec", "sh", "-c", cmd], pre_log)

    def on_update_clicked(self, button):
        repos = self.get_repos()

        pre_log = [f"{name}: {url if url else '— не используется'}" for name, url in repos]
        pre_log.append("Обновление установленных пакетов...")

        env = " ".join(f"{name}={shlex.quote(url)}" for name, url in repos)
        cmd = f"{env} update-kompas-v25"

        self.start_action("Обновление", ["pkexec", "sh", "-c", cmd], pre_log)

    def on_remove_clicked(self, button):
        # После dnf remove остаются хвосты: dummy-пакет, закрывавший зависимость
        # пакета регистрации kactivation, и каталоги /opt/ascon/*v25* с симлинками,
        # не принадлежащими rpm (их создаёт setup-kompas-v25). Дочищаем.
        self.start_action(
            "Удаление",
            ["pkexec", "dnf", "remove", "-y", "ascon*kompas*v25*"],
            ["Удаление пакетов..."],
        )

    def on_purge_clicked(self, button):
        dialog = Gtk.MessageDialog(
            transient_for=self.window,
            flags=0,
            message_type=Gtk.MessageType.WARNING,
            buttons=Gtk.ButtonsType.YES_NO,
            text="Подтверждаете полную очистку?",
            secondary_text="Данное действие удалит все приложения АСКОН, удалит все директории, "
                           "временные файлы и файлы конфигурации, созданные приложениями АСКОН, "
                           "а также удалит пользователей и группы (polynom, ascon, ascon-kompas), "
                           "созданные пакетами при установке.",
        )
        answer = dialog.run()
        dialog.destroy()
        if answer != Gtk.ResponseType.YES:
            return
        cmd = (
            # PolynomAppServer (polynom.service, Restart=always) может пережить
            # dnf remove: если polynomlib не запустилась, %preun не останавливает
            # сервисы, и тогда userdel polynom падает «used by process»
            'systemctl stop polynom.service polynomlib.service polynompg.service '
            '2>/dev/null\n'
            # «Нет совпадений» от dnf — не ошибка: пакеты могли быть удалены
            # раньше; проверяем результат по факту — остались ли ascon-пакеты
            'dnf remove -y "ascon*" 2>/dev/null || true\n'
            'if rpm -qa "ascon*" | grep -q .; then '
            'echo "Ошибка: не удалось удалить пакеты ascon" >&2; exit 1; fi\n'
            f'rpm -q {DUMMY_PKG} >/dev/null 2>&1 && '
            f'dnf remove -y {DUMMY_PKG} || true\n'
            'rm -rf /opt/ascon* /opt/guardant '
            '/etc/ascon /var/lib/ascon /var/log/ascon\n'
            'userdel -r polynom 2>/dev/null\n'
            'groupdel ascon 2>/dev/null; groupdel ascon-kompas 2>/dev/null\n'
            'rm -f /etc/profile.d/ascon-kompas-sdk.sh '
            '/usr/bin/ascon-tutorial-viewer /etc/alternatives/ascon-tutorial-viewer\n'
            'rmdir /usr/share/doc/ascon-kompas-* 2>/dev/null\n'
            'exit 0'
        )
        self.start_action(
            "Удаление и очистка",
            ["pkexec", "sh", "-c", cmd],
            ["Удаление пакетов...", "Очистка файлов и учётных записей..."],
        )

    def on_delete_event(self, widget, event):
        """Подтверждение закрытия окна во время выполняющейся операции."""
        if not self.action_running:
            return False
        dialog = Gtk.MessageDialog(
            transient_for=self.window,
            flags=0,
            message_type=Gtk.MessageType.QUESTION,
            buttons=Gtk.ButtonsType.YES_NO,
            text="Выполняется операция"
        )
        dialog.format_secondary_text(
            "Закрыть окно? Операция продолжит выполняться в фоне."
        )
        answer = dialog.run()
        dialog.destroy()
        return answer != Gtk.ResponseType.YES

    def action_finished(self, action, returncode, error=None):
        self.set_buttons_sensitive(True)
        self.action_running = False
        self.status_label.set_text("")
        if returncode == 0:
            self.progress.set_fraction(1.0)
            self.progress.set_text(f"{action} завершено успешно")
            self.log(f"\n{action} завершено успешно.")
        else:
            self.progress.set_text(f"Ошибка: {action}")
            if error:
                self.log(f"\nОшибка: {error}")
            dialog = Gtk.MessageDialog(
                transient_for=self.window,
                flags=0,
                message_type=Gtk.MessageType.ERROR,
                buttons=Gtk.ButtonsType.OK,
                text=f"Ошибка: {action}"
            )
            dialog.format_secondary_text(error if error else "Смотрите лог для подробностей")
            dialog.run()
            dialog.destroy()
        self.detect_installed()

if __name__ == "__main__":
    app = InstallKompasGUI()
    Gtk.main()
