#!/usr/bin/env python3
""" Module providing initialization of the ISO Constructor application """

import os
import sys
import subprocess
import grp
import getpass
import gettext
import json
from configparser import ConfigParser
import logging
from logging.handlers import TimedRotatingFileHandler
import apt
from app_shared import (
    show_dialog,
    DialogIcon,
    select_path,
    PathSelectionAction,
    get_lsb_release_info
)
from terminal import Terminal

try:
    import debugpy
    IS_DEBUGGING = debugpy.is_client_connected()
except ImportError:
    IS_DEBUGGING = False

import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk, Gdk

# i18n: http://docs.python.org/3/library/gettext.html
_ = gettext.translation('iso-constructor', fallback=True).gettext

# Application name
APP_NAME = "ISO Constructor"

# Configure logging
SAVE_DIR = os.path.expanduser("~/.config/iso-constructor")
LOG_PATH = f"{SAVE_DIR}/iso-constructor.log"
# Check if old save directory exists
OLD_DIR = os.path.expanduser("~/.iso-constructor")
if os.path.exists(OLD_DIR):
    os.rename(OLD_DIR, SAVE_DIR)
else:
    os.makedirs(SAVE_DIR, exist_ok=True)

# Create a timed rotating file handler that keeps logs for 10 days
handler = TimedRotatingFileHandler(
    LOG_PATH,
    when='midnight',  # Rotate at midnight
    interval=1,       # Rotate every 1 day
    backupCount=10,   # Keep 10 days of logs
)

# Set the logging level and format
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s',
                              datefmt='%Y-%m-%d %H:%M:%S')
handler.setFormatter(formatter)

# Rotate logs immediately on startup
handler.doRollover()
logger = logging.getLogger()
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)

# Global exception handler
def handle_exception(exc_type, exc_value, exc_traceback):
    """
    Global exception handler to log unexpected errors.
    """
    logging.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
    show_dialog(
        APP_NAME,
        _("An unexpected error occurred.\nPlease check the log file for details."),
        icon=DialogIcon.ERROR
    )

# Set the global exception handler
sys.excepthook = handle_exception


class Constructor(Gtk.Application):
    """
    ISO Constructor main class.
    """
    def __init__(self):
        super().__init__(application_id='org.solydxk.iso_constructor')

        self.script_dir: str = os.path.abspath(os.path.dirname(__file__))
        self.scripts_dir: str = os.path.join(self.script_dir, "scripts")
        if IS_DEBUGGING:
            # When debugging: use the scripts from the iso-constructor package
            self.scripts_dir = "/usr/lib/iso-constructor/scripts"
        self.data_dir: str = os.path.join(self.script_dir, "data")
        self.save_dir: str = SAVE_DIR
        self.log_path: str = LOG_PATH
        self.conf_file: str = os.path.join(self.save_dir, 'iso-constructor.conf')

        # Load configuration from json
        with open(file=os.path.join(self.data_dir, "config.json"),
                  mode="r",
                  encoding="utf-8") as f:
            self.json_config: dict = json.load(f)

        # Log start date and time
        self.logging: logging.Logger = logging
        self.logging.info(f"{APP_NAME} started")

        # Load the UI file
        self.builder: Gtk.Builder = Gtk.Builder.new_from_file(
            f'{self.script_dir}/iso-constructor.ui'
        )

        # Connect the signals
        # Main window
        self.window: Gtk.Window = self.builder.get_object('constructor_window')
        self.window.connect("close-request", self.on_constructor_window_close_request)
        self.builder.get_object("btn_add").connect("clicked", self.on_btn_add_clicked)
        self.builder.get_object("btn_remove").connect("clicked", self.on_btn_remove_clicked)
        self.builder.get_object("btn_edit").connect("clicked", self.on_btn_edit_clicked)
        self.builder.get_object("btn_upgrade").connect("clicked", self.on_btn_upgrade_clicked)
        self.builder.get_object("btn_build_iso").connect("clicked", self.on_btn_build_iso_clicked)
        self.builder.get_object("btn_about").connect("clicked", self.on_btn_about_clicked)
        self.builder.get_object("btn_help").connect("clicked", self.on_btn_help_clicked)
        self.builder.get_object("btn_log").connect("clicked", self.on_btn_log_clicked)
        self.builder.get_object("chk_select_all").connect("toggled", self.on_chk_select_all_toggled)
        # Distro window
        self.builder.get_object("chk_from_iso").connect("toggled", self.on_chk_from_iso_toggled)
        self.builder.get_object("btn_iso").connect("clicked", self.on_btn_iso_clicked)
        self.builder.get_object("btn_dir").connect("clicked", self.on_btn_dir_clicked)
        self.builder.get_object("btn_cancel").connect("clicked", self.on_btn_cancel_clicked)
        self.builder.get_object("btn_save").connect("clicked", self.on_btn_save_clicked)
        self.builder.get_object("txt_iso").connect("changed", self.on_txt_iso_changed)
        self.builder.get_object("txt_dir").connect("changed", self.on_txt_dir_changed)

        # Instantiate the ConfigParser
        self.config = ConfigParser()

        # Hold a list with distribution paths
        self.distros = []

        # Parse config file
        self.config.read(self.conf_file)
        self.distros = self.get_distros()

        # Set saved sizes
        window_width, window_height, distros_height = self.get_settings()
        self.window.set_default_size(window_width, window_height)
        self.paned: Gtk.Paned = self.builder.get_object('paned')
        self.paned.set_position(distros_height)

        # Main window objects
        self.listbox_distros: Gtk.ListBox = self.builder.get_object('listbox_distros')
        self.btn_add: Gtk.Button = self.builder.get_object('btn_add')
        self.btn_log: Gtk.Button = self.builder.get_object('btn_log')
        self.chk_selectall: Gtk.CheckButton = self.builder.get_object('chk_select_all')
        self.btn_remove: Gtk.Button = self.builder.get_object('btn_remove')
        self.btn_edit: Gtk.Button = self.builder.get_object('btn_edit')
        self.btn_upgrade: Gtk.Button = self.builder.get_object('btn_upgrade')
        self.btn_buildiso: Gtk.Button = self.builder.get_object('btn_build_iso')

        # Add iso window objects
        self.window_adddistro: Gtk.Window = self.builder.get_object('add_distro_window')
        self.txt_iso: Gtk.Entry = self.builder.get_object('txt_iso')
        self.btn_iso: Gtk.Button = self.builder.get_object('btn_iso')
        self.txt_dir: Gtk.Entry = self.builder.get_object('txt_dir')
        self.btn_dir: Gtk.Button = self.builder.get_object('btn_dir')
        self.btn_save: Gtk.Button = self.builder.get_object('btn_save')
        self.lbl_iso: Gtk.Label = self.builder.get_object('lbl_iso')
        self.lbl_dir: Gtk.Label = self.builder.get_object('lbl_dir')
        self.chk_fromiso: Gtk.CheckButton = self.builder.get_object('chk_from_iso')

        # Terminal
        # Make sure that vexpand and hexpand are set for sw_tve in the UI
        self.terminal = Terminal(self)
        self.terminal.start_shell()
        self.terminal.enable_input(False)
        #self.terminal.show_button_bar(False)
        self.terminal.connect("commands_done", self._on_commands_done)
        self.builder.get_object("sw_tve").set_child(self.terminal)

        # Set the application name
        self.window.set_title(APP_NAME)

        # Init
        self.iso = None
        self.dir = None
        self.chk_fromiso.set_active(True)
        self._skip_select_all = False
        self._unpack_iso = False

        self._pending_paths = []
        self._current_script = None
        self._in_root_dir_flag = False

        # Listbox with distributions
        self.selected_distros = []

        # Load CSS
        self.display: Gdk.Display = Gdk.Display.get_default()
        css_path = os.path.join(self.script_dir, "style", "iso-constructor.css")
        css_provider = Gtk.CssProvider()
        self.logging.info(f"Loading CSS from {css_path}")
        css_provider.load_from_path(css_path)
        Gtk.StyleContext.add_provider_for_display(self.display,
                                                  css_provider,
                                                  Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

    def do_activate(self, *args, **kwargs) -> None:
        """
        Called when the application is activated.
        Overrides Gtk.Application.do_activate.
        """
        # Get the main window and show it
        self.window.set_application(self)
        self.window.present()

        # Fill the listbox with distributions
        self.fill_distros()

        # Run the setup script
        self.run_setup_iso_builder()

    # ===============================================
    # Main Window Functions
    # ===============================================

    def on_btn_add_clicked(self, _widget) -> None:
        """
        Add distribution from ISO file.
        """
        self.window_adddistro.present()

    def on_btn_remove_clicked(self, _widget) -> None:
        """
        remove selected distribution(s).
        """
        for path in self.selected_distros:
            answer = show_dialog(_("Remove distribution"),
                                 _("Are you sure you want to remove "
                                 "the selected distribution from the list?\n"
                                 "(This will not remove the directory and its data)"),
                                 self.window,
                                 DialogIcon.QUESTION,
                                 ["YES", "NO"])
            if answer:
                self.save_distro(distro_path=path, add_distro=False)
        self.fill_distros()

    def on_btn_edit_clicked(self, _widget) -> None:
        """
        Edit selected distribution(s).
        """
        self._terminal_exec(script_name="chroot-dir.sh", in_root_dir=True)

    def on_btn_upgrade_clicked(self, _widget) -> None:
        """
        Apt upgrade selected distribution(s).
        """
        self._terminal_exec(script_name="upgrade.sh")

    def on_btn_build_iso_clicked(self, _widget) -> None:
        """
        Build ISOs from selected distribution(s).
        """
        self._terminal_exec(script_name="build.sh")

    def on_chk_select_all_toggled(self, widget: Gtk.CheckButton) -> None:
        """ Select/Deselect all listed distributions. """
        if self._skip_select_all:
            return

        # Iterate through all rows in the listbox
        for row in self.listbox_distros:
            child = row.get_child()
            if isinstance(child, Gtk.Box) and isinstance(child.get_first_child(), Gtk.CheckButton):
                child.get_first_child().set_active(widget.get_active())

    def on_btn_help_clicked(self, _widget) -> None:
        """
        Open the help file in the default browser.
        """
        help_path = os.path.join(self.script_dir, "help.html")
        if not os.path.exists(help_path):
            return
        os.system(f"xdg-open {help_path}")

    def on_btn_log_clicked(self, _widget) -> None:
        """
        Open the log file in a text editor.
        """
        if not os.path.exists(self.log_path):
            show_dialog(APP_NAME,
                        _("Log file not found"),
                        self.window,
                        DialogIcon.WARNING)
            return
        os.system(f"xdg-open {self.log_path}")

    def on_constructor_window_close_request(self, _widget) -> bool:
        """ Save Settings """
        self.save_settings()
        return False  # Return False to allow the window to close

    def _terminal_exec(self, script_name, in_root_dir=False):
        if not self.selected_distros:
            return

        self.enable_gui_elements(False)
        self.terminal.enable_input(True)

        # Build the commands and save them in a list
        root = "/root" if in_root_dir else ""
        commands = [
            f'pkexec --disable-internal-agent {self.scripts_dir}/{script_name} {path}{root}' \
            for path in self.selected_distros
        ]
        self.terminal.clear_terminal()
        self.terminal.run_commands(commands)

    def _on_commands_done(self, _widget):
        self.logging.info("All commands finished (commands_done signal received).")

        # Do not allow terminal input
        self.terminal.enable_input(False)

        if self._unpack_iso:
            self.save_distro(self.dir)
            self.fill_distros()

        # Re-enable the buttons
        self.enable_gui_elements(True)

    # ===============================================
    # Add ISO Window Functions
    # ===============================================

    def on_btn_iso_clicked(self, _widget) -> None:
        """
        Select ISO file.
        """
        fle_filter = Gtk.FileFilter()
        fle_filter.set_name("ISO")
        fle_filter.add_mime_type("application/x-cd-image")
        fle_filter.add_pattern("*.iso")

        start_dir = None
        if os.path.exists(os.path.dirname(self.txt_iso.get_text())):
            start_dir = os.path.dirname(self.txt_iso.get_text())

        file_path = select_path(main_window=self.window,
                                action=PathSelectionAction.FILE,
                                filter_patterns=["*.iso"],
                                start_directory=start_dir)

        if file_path is not None:
            self.txt_iso.set_text(file_path)

    def on_btn_dir_clicked(self, _widget) -> None:
        """
        Select target directory to unpack ISO into.
        """
        start_dir = None
        if os.path.exists(self.txt_dir.get_text()):
            start_dir = self.txt_dir.get_text()

        dir_text = select_path(main_window=self.window,
                               action=PathSelectionAction.DIRECTORY,
                               start_directory=start_dir)

        if dir_text is not None:
            self.txt_dir.set_text(dir_text)

    def on_btn_save_clicked(self, _widget) -> None:
        """
        Unpack ISO to target directory.
        """
        self._unpack_iso = False
        self.iso = ''
        if self.chk_fromiso.get_active():
            self.iso = self.txt_iso.get_text()
        self.dir = self.txt_dir.get_text()

        title = _("Save existing working directory")
        if not self.iso:
            title = _("Unpack ISO and save")

        if not os.path.exists(self.dir):
            os.makedirs(self.dir)

        if not os.path.exists(self.dir):
            show_dialog(title=title,
                        text=_(f"Could not create directory {self.dir}: exiting"),
                        main_window=self.window,
                        icon=DialogIcon.ERROR)
        else:
            self.window_adddistro.set_visible(False)
            if self.iso != "":
                if not os.path.exists(self.iso):
                    show_dialog(title=self.btn_save.get_label(),
                                text=_(f"The path to the ISO file does not exist:\n{self.iso}"),
                                main_window=self.window,
                                icon=DialogIcon.ERROR)
                    return
                if os.listdir(self.dir):
                    answer = show_dialog(
                        title=self.btn_save.get_label(),
                        text=_("The destination directory is not empty.\n" \
                              f"Are you sure you want to overwrite all data in {self.dir}?"),
                        main_window=self.window,
                        icon=DialogIcon.QUESTION,
                        buttons=["Yes", "No"]
                    )
                    if not answer:
                        return

                self.enable_gui_elements(False)
                self.logging.info(f'Start unpacking {self.iso} to {self.dir}')
                self._unpack_iso = True

                # Start unpacking the ISO
                self.terminal.enable_input(True)
                self.terminal.run_commands(
                    [f'pkexec --disable-internal-agent {self.scripts_dir}/unpack.sh "{self.iso}" "{self.dir}"']
                )
            else:
                self.save_distro(self.dir)
                self.fill_distros()
                self.logging.info(f'Added existing working directory {self.dir}')

    def on_btn_cancel_clicked(self, _widget) -> None:
        """
        Cancel unpacking ISO.
        """
        self.window_adddistro.set_visible(False)

    def on_add_distro_window_delete_event(self, _widget, _data=None) -> bool:
        """
        Hide the Add Distro window.
        """
        self.window_adddistro.set_visible(False)
        return True

    def on_txt_iso_changed(self, _widget) -> None:
        """
        Set objects sensitivity when txt_iso is changed.
        """
        path = self.txt_iso.get_text()
        if os.path.exists(path):
            self.txt_dir.set_sensitive(True)
            self.btn_dir.set_sensitive(True)
            if os.path.exists(self.txt_dir.get_text()):
                self.btn_save.set_sensitive(True)
        else:
            self.txt_dir.set_sensitive(False)
            self.btn_dir.set_sensitive(False)
            self.btn_save.set_sensitive(False)

    def on_txt_dir_changed(self, _widget) -> None:
        """
        Set objects sensitivity when txt_dir is changed.
        """
        bln_from_iso = self.chk_fromiso.get_active()
        iso_path = self.txt_iso.get_text()
        dir_text = self.txt_dir.get_text()
        self.btn_save.set_sensitive(False)
        if os.path.exists(dir_text):
            if bln_from_iso:
                if os.path.exists(iso_path):
                    self.btn_save.set_sensitive(True)
            else:
                self.btn_save.set_sensitive(True)

    def on_chk_from_iso_toggled(self, widget: Gtk.CheckButton) -> None:
        """
        When checked: unpack ISO.
        When unchecked: use target directory as-is.
        """
        from_iso = widget.get_active()

        self.lbl_iso.set_sensitive(from_iso)
        self.txt_iso.set_sensitive(from_iso)
        self.btn_iso.set_sensitive(from_iso)
        self.txt_dir.set_sensitive(not from_iso)
        self.btn_dir.set_sensitive(not from_iso)
        self.btn_save.set_sensitive(not from_iso)

        if from_iso:
            self.lbl_dir.set_text(_("Unpack ISO to directory"))
            self.btn_save.set_label(_("Unpack & Save"))
        else:
            self.lbl_dir.set_text(_("Work directory"))
            self.btn_save.set_label(_("Save"))
            self.txt_iso.set_text("")

    # ===============================================
    # General functions
    # ===============================================

    def fill_distros(self):
        """
        Fill the listbox with distributions.
        """
        self.listbox_distros.remove_all()

        for distro in self.distros:
            box = Gtk.Box()
            box.set_orientation(Gtk.Orientation.HORIZONTAL)
            box.set_spacing(24)
            box.set_css_classes(['distro_box'])

            # Add checkbutton
            try:
                name = distro["name"]
            except KeyError:
                name = distro["path"]
            chk = Gtk.CheckButton(label=name)
            chk.distro = distro["path"]
            chk.set_size_request(300, -1)   # -1 = natural height
            chk.set_css_name("checkbutton")
            chk.connect('toggled', self.on_distro_toggled)
            box.append(chk)

            # Add distro path
            if distro["path"] != name:
                lbl = Gtk.Label(label=distro["path"])
                lbl.set_css_name("label")
                box.append(lbl)

            self.listbox_distros.append(box)

    def run_setup_iso_builder(self) -> None:
        """
        Run the setup script.
        """
        group = "iso-builder"
        user = getpass.getuser()

        # --- 1. Python-side pre-check: is user already in the group? ---
        try:
            group_info = grp.getgrnam(group)
            if user in group_info.gr_mem:
                self.logging.info(f"User {user} already in {group}, skipping setup.")
                return
        except KeyError:
            # group does not exist > must run pkexec script
            pass

        # --- 2. Run pkexec script only if needed ---
        script = os.path.join(self.scripts_dir, "setup_iso_builder.sh")

        try:
            with subprocess.Popen(
                ["pkexec", script],
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True
            ) as proc:
                stdout, stderr = proc.communicate()

                if stdout:
                    self.logging.info(stdout.strip())
                if stderr:
                    self.logging.error(stderr.strip())

                # Exit code 100 means: user was added to iso-builder group
                if proc.returncode == 100:
                    show_dialog(
                        _("Group Updated"),
                        _("You have been added to the iso-builder group.\n\n"
                        "You must log out and log back in for the changes to take effect."),
                        self.window,
                        DialogIcon.INFO
                    )
                elif proc.returncode != 0:
                    self.logging.error(f"Setup script failed with exit code {proc.returncode}")
                else:
                    self.logging.info("iso-builder setup completed successfully")

        except Exception as e:  # pylint: disable=broad-except
            self.logging.error(f"Failed to run setup script: {e}")

    def on_distro_toggled(self, checkbox: Gtk.CheckButton) -> None:
        """ Callback function for toggled checkboxes in a listbox """
        if not checkbox.get_active():
            if checkbox.distro in self.selected_distros:
                self.selected_distros.remove(checkbox.distro)

            if self.chk_selectall.get_active():
                self._skip_select_all = True
                self.chk_selectall.set_active(False)
                self._skip_select_all = False
        else:
            if checkbox.distro not in self.selected_distros:
                self.selected_distros.append(checkbox.distro)

            toggled_rows = len(self.selected_distros)
            total_rows = len(self.distros)
            if toggled_rows == total_rows:
                self._skip_select_all = True
                self.chk_selectall.set_active(True)
                self._skip_select_all = False

    def enable_gui_elements(self, enable: bool) -> None:
        """
        Enable/Disable GUI elements.
        """
        if not enable:
            #self.terminal.set_input_enabled(True)
            self.chk_selectall.set_sensitive(False)
            self.listbox_distros.set_sensitive(False)
            self.btn_add.set_sensitive(False)
            self.btn_buildiso.set_sensitive(False)
            self.btn_edit.set_sensitive(False)
            self.btn_remove.set_sensitive(False)
            self.btn_upgrade.set_sensitive(False)
            self.btn_dir.set_sensitive(False)
        else:
            #self.terminal.set_input_enabled(False)
            self.chk_selectall.set_sensitive(True)
            self.listbox_distros.set_sensitive(True)
            self.btn_add.set_sensitive(True)
            self.btn_buildiso.set_sensitive(True)
            self.btn_edit.set_sensitive(True)
            self.btn_remove.set_sensitive(True)
            self.btn_upgrade.set_sensitive(True)
            self.btn_dir.set_sensitive(True)

    def get_distros(self) -> list:
        """ Get list with distributions """
        distros_info = []
        distros = self.config.get(
            'DISTROS', 'distro_paths', fallback='').split(';')
        for distro in distros:
            distro = get_lsb_release_info(distro)
            if distro:
                distros_info.append(distro)
        return distros_info

    def save_distros(self) -> None:
        """ Save distributions to the config file """
        if 'DISTROS' not in self.config.sections():
            self.config.add_section('DISTROS')

        distro_paths = []
        for distro in self.distros:
            distro_paths.append(distro["path"])

        self.config.set('DISTROS', 'distro_paths', ';'.join(distro_paths))
        self.save_config()

    def save_distro(self, distro_path: str, add_distro: bool=True) -> None:
        """ Add or remove distro_path """
        if add_distro:
            distro = get_lsb_release_info(distro_path)
            if distro:
                self.distros.append(distro)
        else:
            for distro in self.distros:
                if distro["path"] == distro_path:
                    self.distros.remove(distro)
                    break

        # Sort list by path
        self.distros = sorted(self.distros, key=lambda d: d["path"])
        self.save_distros()

        self.iso = ""
        self.dir = ""

    def get_settings(self) -> tuple:
        """ Get tupil with settings """
        return (self.config.getint('SETTINGS', 'window_width', fallback=800),
                self.config.getint('SETTINGS', 'window_height', fallback=600),
                self.config.getint('SETTINGS', 'distros_height', fallback=200))

    def save_settings(self) -> None:
        """ Save settings """
        if 'SETTINGS' not in self.config.sections():
            self.config.add_section('SETTINGS')
        window_width = self.window.get_width()
        window_height = self.window.get_height()
        distros_height = self.paned.get_position()
        self.config.set('SETTINGS', 'window_width', str(window_width))
        self.config.set('SETTINGS', 'window_height', str(window_height))
        self.config.set('SETTINGS', 'distros_height', str(distros_height))
        self.save_config()

    def save_config(self) -> None:
        """ Save the current config object to file """
        with open(file=self.conf_file, mode='w', encoding='utf-8') as conf_fle:
            self.config.write(conf_fle)

    def on_btn_about_clicked(self, _button: Gtk.Button) -> None:
        """
        Show the about window.
        """
        try:
            version = apt.Cache()['iso-constructor'].versions[0].source_version
        except KeyError:
            version = ''

        about = Gtk.AboutDialog(
            transient_for=self.window,  # self is your main window
            modal=True,
            program_name=APP_NAME,
            version=version,
            copyright=f"© 2026 {self.json_config.get('authors')[0]}",
            license_type=Gtk.License.GPL_3_0,
            website=self.json_config.get("home"),
            website_label="Website",
            authors=self.json_config.get("authors"),
            logo_icon_name=self.get_application_id()
        )

        about.present()

if __name__ == '__main__':
    # Start the app
    app = Constructor()
    app.run(None)
