import socket
import sys
import os
import threading
import logging
import traceback
import requests
from datetime import datetime
from pypos.core.utils.logging_setup import (
    clear_log_context,
    configure_logging,
    get_log_prune_interval_minutes,
    prune_logs_now,
    set_log_context,
)
from pypos.core.utils.vendor_dependency_policy import validate_escpos_vendor_lock

configure_logging()
LOGGER = logging.getLogger(__name__)
validate_escpos_vendor_lock(LOGGER)
from PySide6.QtWidgets import QApplication, QStackedWidget
from pypos.modules.auth.views.login_window import LoginWindow
from pypos.modules.dashboard.views.dashboard_window import DashboardWindow
os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1")
from pypos.modules.sinkronisasi.controllers.sinkron_controller import SinkronController
from pypos.modules.sinkronisasi.controllers.sync_flow_controller import SyncFlowController
from pypos.modules.auth.controllers.login_controller import LoginController
from pypos.modules.sinkronisasi.views.sinkron_view import SinkronView
from PySide6.QtWidgets import QMessageBox, QDialog, QLabel, QVBoxLayout, QProgressBar
from pypos.core.utils.device_utils import get_device_id, cek_device_terdaftar, cek_device_pending  # Pastikan sudah kamu import
from pypos.modules.auth.views.device_registration_dialog import DeviceRegistrationDialog
from PySide6.QtWidgets import QApplication  # âœ… Jangan lupa pastikan import ini
from PySide6.QtCore import QObject, Signal,QEventLoop, QCoreApplication, Qt, QLockFile
from pypos.core.utils.audit_logger import log_audit
from PySide6.QtCore import QTimer
from pypos.modules.sinkronisasi.models.sinkron_model import SinkronModel
from pypos.core.utils.config_utils import (
    read_config,
    get_jwt_runtime_config,
    get_sync_watch_tables,
)
from pypos.core.utils.db_helper import clear_all_known_databases
from pypos.core.utils.http_retry import get_with_retry, request_with_retry
from pypos.core.utils.ui_message_utils import sanitize_ui_message
_app_cfg = read_config()
_qt_platform = str(_app_cfg.get("qt_platform") or "").strip() or "windows"
os.environ["QT_QPA_PLATFORM"] = _qt_platform
from pypos.core.utils.session_manager import SessionManager
from pypos.core.utils.session_context import set_session_manager
from pypos.core.utils.app_state_utils import get_bool, set_bool, bootstrap_if_missing
from pypos.core.database.schema_migrator import run_schema_migrations_once
from pypos.modules.penjualan.config.penjualan_config import (
    get_startup_ppn_mode_audit_sample_limit,
    is_startup_ppn_mode_audit_enabled,
)
from pypos.modules.penjualan.services.ppn_mode_startup_audit_service import PpnModeStartupAuditService
from pypos.core.utils.app_runtime_ui_use_case_service import AppRuntimeUiUseCaseService
from pypos.modules.auth.services.employee_seed_use_case_service import EmployeeSeedUseCaseService

# from controllers.login_controller import LoginController  # import controller loginmu
def _install_global_exception_handler():
    previous_hook = sys.excepthook

    def _handle_exception(exc_type, exc_value, exc_tb):
        if issubclass(exc_type, KeyboardInterrupt):
            if previous_hook:
                previous_hook(exc_type, exc_value, exc_tb)
            return

        crash_path = ""
        try:
            from pypos.core.utils.path_utils import get_app_data_dir

            log_dir = os.path.join(get_app_data_dir(), "logs")
            os.makedirs(log_dir, exist_ok=True)
            crash_path = os.path.join(log_dir, "crash.log")
            with open(crash_path, "a", encoding="utf-8") as fh:
                fh.write(f"\n[{datetime.now().isoformat()}] Unhandled exception\n")
                traceback.print_exception(exc_type, exc_value, exc_tb, file=fh)
            LOGGER.exception("Unhandled exception", exc_info=(exc_type, exc_value, exc_tb))
        except APP_NON_FATAL_EXCEPTIONS as crash_log_exc:
            LOGGER.error("Gagal simpan crash log global: %s", crash_log_exc)
            traceback.print_exception(exc_type, exc_value, exc_tb)

        try:
            message = "Terjadi kesalahan tidak terduga."
            if crash_path:
                message += f"\nDetail tersimpan di:\n{crash_path}"
            QMessageBox.critical(None, "Aplikasi Error", message)
        except APP_NON_FATAL_EXCEPTIONS as ui_exc:
            LOGGER.error("Gagal tampilkan dialog error global: %s", ui_exc)

    sys.excepthook = _handle_exception


def _acquire_single_instance_lock():
    from pypos.core.utils.path_utils import get_app_data_dir

    runtime_dir = os.path.join(get_app_data_dir(), "runtime")
    os.makedirs(runtime_dir, exist_ok=True)
    lock_file = QLockFile(os.path.join(runtime_dir, "pypos.lock"))
    lock_file.setStaleLockTime(0)
    if not lock_file.tryLock(100):
        return None
    return lock_file


# edited by glg
EMPLOYEE_SEED_PENDING_KEY = "per_employee_seed_pending"
EMPLOYEE_SEED_DONE_KEY = "per_employee_seed_done"
APP_NON_FATAL_EXCEPTIONS = (
    TypeError,
    ValueError,
    KeyError,
    AttributeError,
    RuntimeError,
    OSError,
    LookupError,
    ArithmeticError,
    ImportError,
)


def _format_employee_seed_error_message(exc):
    return _employee_seed_use_case_service.format_error_message(exc)


# edited by glg
_employee_seed_use_case_service = EmployeeSeedUseCaseService(
    logger=LOGGER,
    seed_pending_key=EMPLOYEE_SEED_PENDING_KEY,
    seed_done_key=EMPLOYEE_SEED_DONE_KEY,
)


# edited by glg
def _ensure_per_employee_seed(device_id, strict=False):
    return _employee_seed_use_case_service.ensure_seed(
        device_id=device_id,
        strict=bool(strict),
    )

class AppController(QStackedWidget):
    def __init__(self):
        super().__init__()

        self.model_sinkron = SinkronModel()
        self.current_user = None
        self.session = SessionManager()
        set_session_manager(self.session)
        self.dashboard_window = None
        self.active_threads = []
        self.active_timers = []
        self.sinkron_controller = None
        self.sync_flow_bg_controller = None
        # edited by glg
        self._shutdown_feedback_dialog = None
        self._shutdown_feedback_label = None
        self._shutdown_feedback_progress = None
        self._shutdown_feedback_progress_value = 0
        self._shutdown_in_progress = False
        self.login_window = LoginWindow(None)  # sementara None dulu
        self.login_controller = LoginController(
            login_view=self.login_window,
            app=self
        )
        self.login_window.controller = self.login_controller  # inject setelah login_controller dibuat
        self.addWidget(self.login_window)
        self.setCurrentWidget(self.login_window)
        self._apply_login_window_size()

    def tampilkan_widget(self, widget):
            self.addWidget(widget)
            self.setCurrentWidget(widget)

    # edited by glg
    def _resolve_login_window_size(self):
        from PySide6.QtGui import QGuiApplication

        screen = QGuiApplication.primaryScreen().availableGeometry()
        cfg = read_config()
        try:
            login_scale = float(cfg.get("login_window_scale") or 0.34)
        except APP_NON_FATAL_EXCEPTIONS:
            login_scale = 0.34
        try:
            login_min_size = int(cfg.get("login_window_min_size") or 460)
        except APP_NON_FATAL_EXCEPTIONS:
            login_min_size = 460

        login_scale = max(0.30, login_scale)
        login_min_size = max(460, login_min_size)
        side = int(min(screen.width(), screen.height()) * login_scale)
        side = max(side, login_min_size)
        width = int(side * 1.12)
        height = side
        return screen, width, height

    # edited by glg
    def _apply_login_window_size(self):
        screen, width, height = self._resolve_login_window_size()
        self.setMinimumSize(width, height)
        self.resize(width, height)
        x = screen.x() + (screen.width() - self.width()) // 2
        y = screen.y() + (screen.height() - self.height()) // 2
        self.move(x, y)

    # edited by glg
    def _dispose_dashboard_window(self):
        old_dashboard = getattr(self, "dashboard_window", None)
        if old_dashboard is None:
            return
        # edited by glg
        # Stop sinkronisasi yang mungkin masih berjalan sebelum dashboard ditutup.
        try:
            shutdown_cfg = read_config()
            stop_wait_ms = int(shutdown_cfg.get("sync_stop_wait_timeout_ms") or 2000)
            stop_force = int(shutdown_cfg.get("sync_stop_force_terminate") or 0) == 1
            dashboard_controller = getattr(old_dashboard, "dashboard_controller", None)
            if dashboard_controller and hasattr(dashboard_controller, "stop_all_sync"):
                dashboard_controller.stop_all_sync(force=stop_force, wait_timeout_ms=stop_wait_ms)
        except APP_NON_FATAL_EXCEPTIONS as e:
            LOGGER.warning("Gagal stop sync sebelum dispose dashboard: %s", e)
        try:
            self.removeWidget(old_dashboard)
        except APP_NON_FATAL_EXCEPTIONS as exc:
            LOGGER.debug("removeWidget dashboard lama gagal (non-fatal): %s", exc)
        try:
            old_dashboard.blockSignals(True)
        except APP_NON_FATAL_EXCEPTIONS as exc:
            LOGGER.debug("blockSignals dashboard lama gagal (non-fatal): %s", exc)
        try:
            old_dashboard.close()
        except APP_NON_FATAL_EXCEPTIONS as exc:
            LOGGER.debug("close dashboard lama gagal (non-fatal): %s", exc)
        try:
            old_dashboard.deleteLater()
        except APP_NON_FATAL_EXCEPTIONS as exc:
            LOGGER.debug("deleteLater dashboard lama gagal (non-fatal): %s", exc)
        self.dashboard_window = None

    def login_success(self, user_info):
        self.session.login(user_info)
        self.current_user = user_info
        try:
            from pypos.core.utils.device_utils import get_active_device_info
            machine_id = get_device_id()
            active_device = get_active_device_info(machine_id) or {}
            set_log_context(
                user_id=self.current_user.get("id"),
                machine_id=active_device.get("machine_id") or machine_id,
                cabang_id=active_device.get("cabang_id"),
            )
        except APP_NON_FATAL_EXCEPTIONS as e:
            LOGGER.warning("Gagal set log context saat login: %s", e)
        # Setelah login sukses:
        from pypos.core.utils.path_utils import get_db_path
        BASE_DIR = getattr(sys, '_MEIPASS', os.path.abspath(os.path.dirname(sys.argv[0])))
        DB_PATH = get_db_path() # os.path.join(BASE_DIR, 'db', 'beta_sb_pos_sqlite.db')

        log_audit(DB_PATH, self.current_user["id"], "INSERT", "login", self.current_user["id"], "Login berhasil")
        self.show_dashboard(user_info)
        try:
            if get_bool("first_sync_pending", False) and self.dashboard_window:
                QTimer.singleShot(0, self.dashboard_window.buka_menu_sinkron_data)
        except APP_NON_FATAL_EXCEPTIONS as e:
            LOGGER.warning("Auto-open sinkron gagal: %s", e)
        return

    def on_sinkron_selesai(self, user_info, success=True):
        # Popup sudah ditampilkan di controller, langsung pindah ke dashboard
        LOGGER.debug("on_sinkron_selesai dipanggil, success=%s, pindah ke dashboard", success)

        # Hapus sinkron_view dari stack jika ada
        if hasattr(self, 'sinkron_view') and self.sinkron_view is not None:
            self.removeWidget(self.sinkron_view)
            self.sinkron_view.deleteLater()
            self.sinkron_view = None

        if success:
            self.show_dashboard(user_info)
        else:
            LOGGER.error("Sinkronisasi gagal, kembali ke login")
            self.show_login()

    def show_login(self, reset_size=False):
        self.session.logout()
        clear_log_context()
        try:
            device_id = get_device_id()
            from pypos.modules.auth.services.auth_service import AuthService
            from pypos.core.utils.path_utils import get_db_path
            from pypos.core.utils.device_utils import (
                cek_device_ke_server,
                simpan_device_lokal,
                is_device_not_registered,
                hapus_device_lokal,
            )

            is_online = AuthService.is_online()
            if is_online:
                result = cek_device_ke_server(device_id)
                if result.get('status') == 200:
                    data_reg = result.get("dataReg") if isinstance(result, dict) else None
                    server_machine_id = data_reg.get("machine_id") if isinstance(data_reg, dict) else None
                    if server_machine_id and str(server_machine_id) != str(device_id):
                        QMessageBox.critical(self, "Error Deteksi Device",
                                             "Machine ID dari server tidak sesuai dengan device ini.")
                        return
                    device_local = cek_device_terdaftar(device_id)
                    hapus_device_lokal(device_id)
                    ok_save = simpan_device_lokal(data_reg, device_id=device_id) if isinstance(data_reg, dict) else False
                    if not ok_save:
                        QMessageBox.critical(self, "Error Deteksi Device",
                                             "Gagal menyimpan data device lokal dari server.")
                        return
                    if not device_local:
                        _employee_seed_use_case_service.mark_seed_pending()
                    try:
                        sync_info = _ensure_per_employee_seed(
                            device_id=device_id,
                            strict=not bool(device_local),
                        )
                        LOGGER.info(
                            "Seed per_employee saat show_login: seeded=%s rows_synced=%s local_rows=%s",
                            bool(sync_info.get("seeded")),
                            int(sync_info.get("rows_synced") or 0),
                            int(sync_info.get("local_rows") or 0),
                        )
                    except APP_NON_FATAL_EXCEPTIONS as sync_exc:
                        QMessageBox.critical(
                            self,
                            "Sinkronisasi Akun Gagal",
                            _format_employee_seed_error_message(sync_exc),
                        )
                        return
                elif result.get('status') == 202:
                    QMessageBox.information(self, "Registrasi",
                                            "Registrasi menunggu proses approved. Silakan Cek Lagi Lain Kali.")
                    return
                elif result.get('status') == 404 or is_device_not_registered(result):
                    try:
                        clear_result = clear_all_known_databases(strict=True)
                        LOGGER.info("Database dikosongkan total (master+transaksional): %s", clear_result)
                    except APP_NON_FATAL_EXCEPTIONS as e:
                        LOGGER.warning("Gagal kosongkan database: %s", e)
                        QMessageBox.critical(
                            self,
                            "Gagal Reset Data",
                            "Data device tidak valid, tetapi reset database gagal. Hubungi teknisi.",
                        )
                        return
                    dialog = DeviceRegistrationDialog(self)
                    if dialog.exec() == QDialog.Accepted:
                        # edited by glg
                        # Jika registrasi menghasilkan device aktif lokal,
                        # ulangi alur show_login agar bisa lanjut login tanpa restart.
                        if cek_device_terdaftar(device_id):
                            self.show_login(reset_size=reset_size)
                            return
                        if cek_device_pending(device_id):
                            QMessageBox.information(
                                self,
                                "Registrasi",
                                "Registrasi menunggu proses approved. Silakan cek lagi nanti.",
                            )
                            return
                        QMessageBox.warning(
                            self,
                            "Registrasi",
                            "Registrasi belum selesai. Silakan ulangi proses registrasi.",
                        )
                    else:
                        QMessageBox.warning(self, "Registrasi", "Registrasi dibatalkan.")
                    return
                else:
                    raw_reason = result.get('reason', 'Unknown error') if isinstance(result, dict) else "Unknown error"
                    LOGGER.warning("Device check failed (show_login): %s", raw_reason)
                    safe_reason, _ = sanitize_ui_message("critical", str(raw_reason))
                    QMessageBox.critical(self, "Error Deteksi Device",
                                         f"Pemeriksaan device gagal.\n{safe_reason}")
                    return

            device_data = cek_device_terdaftar(device_id)

            device_pending = cek_device_pending(device_id)
            if device_pending:
                QMessageBox.information(self, "Registrasi",
                                        "Registrasi device masih pending.\n"
                                        "Silakan tunggu approval pusat.")
                return

            if not device_data:
                try:
                    clear_result = clear_all_known_databases(strict=True)
                    LOGGER.info("Database dikosongkan total (master+transaksional): %s", clear_result)
                except APP_NON_FATAL_EXCEPTIONS as e:
                    LOGGER.warning("Gagal kosongkan database: %s", e)
                    QMessageBox.critical(
                        self,
                        "Gagal Reset Data",
                        "Data device tidak valid, tetapi reset database gagal. Hubungi teknisi.",
                    )
                    return
                dialog = DeviceRegistrationDialog(self)
                if dialog.exec() == QDialog.Accepted:
                    # edited by glg
                    if cek_device_terdaftar(device_id):
                        self.show_login(reset_size=reset_size)
                        return
                    if cek_device_pending(device_id):
                        QMessageBox.information(
                            self,
                            "Registrasi",
                            "Registrasi menunggu proses approved. Silakan cek lagi nanti.",
                        )
                        return
                    QMessageBox.warning(
                        self,
                        "Registrasi",
                        "Registrasi belum selesai. Silakan ulangi proses registrasi.",
                    )
                else:
                    QMessageBox.warning(self, "Registrasi", "Registrasi dibatalkan.")
                return

        except APP_NON_FATAL_EXCEPTIONS as e:
            LOGGER.error("Gagal mendeteksi device saat show_login", exc_info=True)
            safe_message, _ = sanitize_ui_message("critical", str(e))
            QMessageBox.critical(self, "Error Deteksi Device", f"Gagal mendeteksi device.\n{safe_message}")
            return

        self._dispose_dashboard_window()
        self.setCurrentWidget(self.login_window)

        # âœ… Kembalikan window ke ukuran normal (bukan maximize)
        self.showNormal()

        self._apply_login_window_size()

        # âœ… Clear password untuk security, tapi preserve username jika remember_me dicentang
        self.login_window.input_password.clear()

        # âŒ Jangan clear username - biarkan saved credentials tetap ada
        # Hanya clear jika remember_me TIDAK dicentang
        if not self.login_window.remember_me.isChecked():
            self.login_window.input_username.clear()

        # Set focus ke password field jika username ada, otherwise ke username field
        if self.login_window.input_username.text():
            self.login_window.input_password.setFocus()
        else:
            self.login_window.input_username.setFocus()

    def is_local_online(self, host=None, port=None, timeout=None):
        cfg = read_config()
        host = host or str(cfg.get("local_online_host") or "192.168.1.1")
        port = int(port or cfg.get("local_online_port") or 80)
        timeout = float(timeout or cfg.get("local_online_timeout") or 1)
        try:
            socket.setdefaulttimeout(timeout)
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            s.connect((host, port))
            s.close()
            return True
        except socket.error:
            return False

    def is_online(self):
        config = read_config()
        base_url = config.get("api_base_url", "")
        timeout = config.get("request_timeout", 3)
        if not base_url:
            return False
        try:
            response = get_with_retry(base_url, timeout=timeout)
            return response.status_code < 400
        except APP_NON_FATAL_EXCEPTIONS:
            return False

    # edited by glg
    def is_online_for_operation(self):
        """
        Probe koneksi untuk operasi penting (sinkronisasi/export/login flow).
        Mode ini mempertahankan retry policy global.
        """
        return self.is_online()

    # edited by glg
    def is_online_fast(self, timeout=None):
        """
        Probe koneksi ringan untuk kebutuhan UI indicator.
        Tidak memakai retry berlapis agar tidak mengunci UI terlalu lama.
        """
        config = read_config()
        base_url = str(config.get("api_base_url", "") or "").strip()
        if not base_url:
            return False

        try:
            resolved_timeout = float(timeout if timeout is not None else config.get("dashboard_online_probe_timeout_sec", 1))
        except APP_NON_FATAL_EXCEPTIONS:
            resolved_timeout = 1.0
        resolved_timeout = max(0.2, resolved_timeout)

        # edited by glg
        # Gunakan request_with_retry(max_retry=1) agar probe tetap anti-freeze bila
        # sewaktu-waktu dipanggil dari UI thread, tanpa menambah retry berlapis.
        head_resp = None
        try:
            head_resp = request_with_retry(
                "HEAD",
                base_url,
                timeout=resolved_timeout,
                max_retry=1,
                backoff_sec=0.0,
                backoff_factor=1.0,
                max_backoff_sec=0.1,
                jitter_ratio=0.0,
                retry_statuses=[],
                session=requests,
                retry_on=(requests.RequestException,),
                request_kwargs={"allow_redirects": True, "stream": True},
            )
            status_code = int(getattr(head_resp, "status_code", 0) or 0)
            if status_code and status_code not in (405, 501):
                return True
        except requests.exceptions.HTTPError as exc:
            head_resp = getattr(exc, "response", None)
            status_code = int(getattr(head_resp, "status_code", 0) or 0)
            if status_code and status_code not in (405, 501):
                return True
        except requests.RequestException:
            pass
        except APP_NON_FATAL_EXCEPTIONS:
            pass
        finally:
            try:
                if head_resp is not None and hasattr(head_resp, "close"):
                    head_resp.close()
            except APP_NON_FATAL_EXCEPTIONS as close_exc:
                LOGGER.debug("Gagal close response HEAD probe online: %s", close_exc)

        get_resp = None
        try:
            get_resp = request_with_retry(
                "GET",
                base_url,
                timeout=resolved_timeout,
                max_retry=1,
                backoff_sec=0.0,
                backoff_factor=1.0,
                max_backoff_sec=0.1,
                jitter_ratio=0.0,
                retry_statuses=[],
                session=requests,
                retry_on=(requests.RequestException,),
                request_kwargs={"allow_redirects": True, "stream": True},
            )
            status_code = int(getattr(get_resp, "status_code", 0) or 0)
            return status_code > 0
        except requests.exceptions.HTTPError as exc:
            get_resp = getattr(exc, "response", None)
            status_code = int(getattr(get_resp, "status_code", 0) or 0)
            return status_code > 0
        except requests.RequestException:
            return False
        except APP_NON_FATAL_EXCEPTIONS:
            return False
        finally:
            try:
                if get_resp is not None and hasattr(get_resp, "close"):
                    get_resp.close()
            except APP_NON_FATAL_EXCEPTIONS as close_exc:
                LOGGER.debug("Gagal close response GET probe online: %s", close_exc)

    # edited by glg
    def is_online_for_ui(self, timeout=None):
        """
        Probe koneksi ringan khusus indikator UI.
        """
        return self.is_online_fast(timeout=timeout)

    def show_dashboard(self, user_info):
        if not self.session.is_authenticated():
            self.show_login(reset_size=True)
            return
        LOGGER.debug("Memulai show_dashboard, user_info: %s", user_info)
        # Bersihkan widget dashboard lama jika ada
        if hasattr(self, "dashboard_window") and self.dashboard_window is not None:
            self._dispose_dashboard_window()

        self.current_user = user_info

        LOGGER.debug("Membuat DashboardWindow...")
        self.dashboard_window = DashboardWindow(user_info, self)
        LOGGER.debug("DashboardWindow berhasil dibuat: %s", self.dashboard_window)
        # self.dashboard_window.showMaximized()
        # Cek status koneksi saat masuk dashboard
        # edited by glg
        # Hindari probe jaringan sinkron saat inisialisasi dashboard agar first paint tidak tersendat.
        self.dashboard_window.update_koneksi_status(False)
        QTimer.singleShot(0, self.dashboard_window.cek_koneksi_realtime)

        LOGGER.debug("Menambahkan dashboard_window ke widget utama...")
        self.addWidget(self.dashboard_window)


        self.login_window.hide()
        LOGGER.debug("login_window disembunyikan")
        LOGGER.info("show_dashboard ditampilkan dalam mode maximized")

        self.setCurrentWidget(self.dashboard_window)
        LOGGER.debug("setCurrentWidget ke dashboard_window")

        self.showMaximized()
        LOGGER.debug("Window utama di-showMaximized")
        LOGGER.debug("Window utama disesuaikan ukuran layar")
        cfg = read_config()
        win_x = int(cfg.get("window_pos_x") or 5)
        win_y = int(cfg.get("window_pos_y") or 5)
        self.move(win_x, win_y)

        # âœ… Opsional: batasi juga ukuran login window supaya tidak melar

    def cek_data_update_ke_server(self):
        # Cek update data server
        # Jika ada perubahan:
        if self.server_data_changed():
            LOGGER.info("Data server berubah")
            self.dashboard_window.update_sync_status(True)
        else:
            self.dashboard_window.update_sync_status(False)

    # edited by glg
    def _is_shutdown_feedback_enabled(self, cfg):
        try:
            return int((cfg or {}).get("shutdown_feedback_enabled") or 1) == 1
        except APP_NON_FATAL_EXCEPTIONS:
            return True

    # edited by glg
    def _show_shutdown_feedback(self, cfg, message=""):
        title = str((cfg or {}).get("shutdown_feedback_title") or "Menutup Aplikasi").strip()
        initial = str(
            message
            or (cfg or {}).get("shutdown_feedback_initial_message")
            or "Mohon tunggu, aplikasi akan tertutup total."
        ).strip()

        if self._shutdown_feedback_dialog is None:
            dialog = QDialog(self)
            dialog.setWindowTitle(title or "Menutup Aplikasi")
            dialog.setModal(True)
            dialog.setWindowFlags(Qt.Dialog | Qt.CustomizeWindowHint | Qt.WindowTitleHint)
            dialog.setMinimumWidth(460)
            layout = QVBoxLayout(dialog)
            layout.setContentsMargins(18, 14, 18, 14)
            layout.setSpacing(10)

            label = QLabel(initial, dialog)
            label.setWordWrap(True)

            progress = QProgressBar(dialog)
            progress.setRange(0, 100)
            progress.setValue(0)
            progress.setTextVisible(False)
            # edited by glg
            # Gunakan minimum-height agar indikator tetap adaptif terhadap skala UI.
            progress.setMinimumHeight(18)

            layout.addWidget(label)
            layout.addWidget(progress)
            dialog.setLayout(layout)

            self._shutdown_feedback_dialog = dialog
            self._shutdown_feedback_label = label
            self._shutdown_feedback_progress = progress
            self._shutdown_feedback_progress_value = 0
            dialog.show()
            dialog.raise_()
            dialog.activateWindow()

        self._set_shutdown_feedback_message(initial)
        self._pump_shutdown_feedback()

    # edited by glg
    def _set_shutdown_feedback_message(self, message):
        try:
            if self._shutdown_feedback_label is not None:
                self._shutdown_feedback_label.setText(str(message or "").strip())
            self._pump_shutdown_feedback()
        except APP_NON_FATAL_EXCEPTIONS as exc:
            LOGGER.debug("Gagal update teks shutdown feedback: %s", exc)

    # edited by glg
    def _set_shutdown_feedback_progress(self, percent):
        try:
            if self._shutdown_feedback_progress is None:
                return
            value = int(percent)
            value = max(0, min(100, value))
            self._shutdown_feedback_progress_value = value
            self._shutdown_feedback_progress.setValue(self._shutdown_feedback_progress_value)
        except APP_NON_FATAL_EXCEPTIONS as exc:
            LOGGER.debug("Gagal update progress shutdown feedback: %s", exc)

    # edited by glg
    def _pump_shutdown_feedback(self):
        try:
            QApplication.processEvents(QEventLoop.AllEvents, 30)
        except APP_NON_FATAL_EXCEPTIONS:
            try:
                QApplication.processEvents()
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.debug("Gagal processEvents shutdown feedback: %s", exc)

    # edited by glg
    def _run_blocking_task_with_shutdown_feedback(
        self,
        task_fn,
        wait_message,
        poll_sec=0.12,
        progress_min=0,
        progress_max=100,
    ):
        lock = threading.Lock()
        state = {"progress": int(progress_min)}
        done_event = threading.Event()
        progress_start = max(0, min(100, int(progress_min)))
        progress_end = max(progress_start, min(100, int(progress_max)))

        def _report_progress(stage_percent):
            try:
                stage_value = int(stage_percent)
            except APP_NON_FATAL_EXCEPTIONS:
                stage_value = 0
            stage_value = max(0, min(100, stage_value))
            mapped = progress_start + int((progress_end - progress_start) * stage_value / 100)
            with lock:
                current = int(state.get("progress") or progress_start)
                state["progress"] = max(current, mapped)

        def _runner():
            try:
                state["result"] = task_fn(_report_progress)
            except APP_NON_FATAL_EXCEPTIONS as exc:
                state["error"] = exc
            finally:
                done_event.set()

        worker = threading.Thread(target=_runner, daemon=True)
        worker.start()

        last_progress = progress_start
        self._set_shutdown_feedback_progress(last_progress)

        while not done_event.wait(max(0.05, float(poll_sec or 0.12))):
            with lock:
                current_progress = int(state.get("progress") or progress_start)
            if current_progress != last_progress:
                last_progress = current_progress
                self._set_shutdown_feedback_progress(last_progress)
            if wait_message:
                self._set_shutdown_feedback_message(wait_message)
            self._pump_shutdown_feedback()

        with lock:
            final_progress = int(state.get("progress") or progress_end)
        self._set_shutdown_feedback_progress(max(final_progress, progress_end))

        err = state.get("error")
        if err is not None:
            raise err
        return state.get("result")

    # edited by glg
    def _close_shutdown_feedback(self):
        dialog = self._shutdown_feedback_dialog
        self._shutdown_feedback_dialog = None
        self._shutdown_feedback_label = None
        self._shutdown_feedback_progress = None
        self._shutdown_feedback_progress_value = 0
        if dialog is None:
            return
        try:
            dialog.hide()
        except APP_NON_FATAL_EXCEPTIONS as exc:
            LOGGER.debug("Gagal hide shutdown feedback dialog: %s", exc)
        try:
            dialog.deleteLater()
        except APP_NON_FATAL_EXCEPTIONS as exc:
            LOGGER.debug("Gagal deleteLater shutdown feedback dialog: %s", exc)

    # edited by glg
    def _is_settlement_close_guard_enabled(self, cfg):
        try:
            return int((cfg or {}).get("settlement_close_guard_enabled") or 1) == 1
        except APP_NON_FATAL_EXCEPTIONS:
            return True

    # edited by glg
    def _build_settlement_close_guard_message(self, state):
        snapshot = state if isinstance(state, dict) else {}
        inflight = bool(snapshot.get("inflight"))
        unsent_count = int(snapshot.get("unsent_count") or 0)
        unsent_items = snapshot.get("unsent_items") if isinstance(snapshot.get("unsent_items"), list) else []

        lines = [
            "Aplikasi belum dapat ditutup karena settlement belum terkirim ke server.",
            "Silakan online atau perbaiki jaringan Anda, lalu coba tutup aplikasi lagi.",
        ]
        if inflight:
            lines.append("Status: proses pengiriman settlement masih berjalan.")
        elif unsent_count > 0:
            lines.append(f"Status: {unsent_count} settlement belum terkirim.")

        counters = []
        for item in unsent_items[:3]:
            payload = item.get("settlement_result") if isinstance(item, dict) else {}
            payload = payload if isinstance(payload, dict) else {}
            counter = str(payload.get("counter") or "").strip()
            if counter:
                counters.append(counter)
        if counters:
            lines.append("Counter pending: " + ", ".join(counters))

        if unsent_items:
            first_error = str((unsent_items[0] or {}).get("error") or "").strip()
            if first_error:
                lines.append(f"Keterangan: {first_error}")
        return "\n".join(lines)

    # edited by glg
    def _should_block_close_for_settlement_delivery(self, cfg):
        if not self._is_settlement_close_guard_enabled(cfg):
            return False

        dashboard = getattr(self, "dashboard_window", None)
        controller = getattr(dashboard, "dashboard_controller", None) if dashboard is not None else None
        if controller is None:
            return False
        config_service = getattr(controller, "config_service", None)
        if config_service is None or not hasattr(config_service, "is_settlement_direct_only_mode"):
            return False
        try:
            if not bool(config_service.is_settlement_direct_only_mode()):
                return False
        except APP_NON_FATAL_EXCEPTIONS:
            return False
        if not hasattr(controller, "get_settlement_delivery_guard_state"):
            return False

        state = controller.get_settlement_delivery_guard_state()
        if not bool(state.get("blocking")):
            return False

        retry_before_block = False
        try:
            retry_before_block = int((cfg or {}).get("settlement_close_guard_retry_before_block") or 1) == 1
        except (TypeError, ValueError):
            retry_before_block = True
        if retry_before_block and not bool(state.get("inflight")) and hasattr(controller, "retry_pending_settlement_direct_delivery"):
            try:
                retry_max_items = max(1, int((cfg or {}).get("settlement_close_guard_retry_max_items") or 3))
            except (TypeError, ValueError):
                retry_max_items = 3
            retry_result = controller.retry_pending_settlement_direct_delivery(max_items=retry_max_items)
            LOGGER.info(
                "[ShutdownGuard] Retry settlement direct saat close: attempted=%s success=%s failed=%s remaining=%s inflight=%s",
                int((retry_result or {}).get("attempted") or 0),
                int((retry_result or {}).get("success") or 0),
                int((retry_result or {}).get("failed") or 0),
                int((retry_result or {}).get("remaining") or 0),
                int(bool((retry_result or {}).get("inflight"))),
            )
            state = controller.get_settlement_delivery_guard_state()
            if not bool(state.get("blocking")):
                return False

        title = str((cfg or {}).get("settlement_close_guard_title") or "Penutupan Ditunda").strip()
        message = self._build_settlement_close_guard_message(state)
        safe_message, _ = sanitize_ui_message("warning", message)
        QMessageBox.warning(self, title or "Penutupan Ditunda", safe_message)
        LOGGER.warning(
            "[ShutdownGuard] Penutupan dibatalkan: inflight=%s unsent=%s",
            int(bool(state.get("inflight"))),
            int(state.get("unsent_count") or 0),
        )
        return True


    # edited by glg
    # Pecah hotspot closeEvent: seluruh penghentian timer/UI dipusatkan di helper ini.
    def _stop_shutdown_timers_and_ui(self, *, stop_force: bool, stop_wait_ms: int) -> None:
        if hasattr(self, "sync_check_timer") and self.sync_check_timer:
            LOGGER.debug("Force stopping sync check timer...")
            try:
                self.sync_check_timer.stop()
                self.sync_check_timer.deleteLater()
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.warning("Gagal stop sync_check_timer: %s", exc)

        if not self.dashboard_window:
            return

        if hasattr(self.dashboard_window, "timer"):
            LOGGER.debug("Force stopping dashboard timer...")
            try:
                self.dashboard_window.timer.stop()
                self.dashboard_window.timer.deleteLater()
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.warning("Gagal stop dashboard timer: %s", exc)

        if hasattr(self.dashboard_window, "auto_sync_service") and self.dashboard_window.auto_sync_service:
            try:
                self.dashboard_window.auto_sync_service.stop_sync(
                    force=stop_force,
                    wait_timeout_ms=stop_wait_ms,
                )
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.warning("Gagal stop auto_sync_service: %s", exc)

        if hasattr(self.dashboard_window, "sync_flow_controller") and self.dashboard_window.sync_flow_controller:
            try:
                self.dashboard_window.sync_flow_controller.stop(
                    force=stop_force,
                    wait_timeout_ms=stop_wait_ms,
                )
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.warning("Gagal stop sync_flow_controller: %s", exc)

        if hasattr(self.dashboard_window, "auto_sync_timer"):
            try:
                self.dashboard_window.auto_sync_timer.stop()
                self.dashboard_window.auto_sync_timer.deleteLater()
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.warning("Gagal stop auto_sync_timer: %s", exc)

    # edited by glg
    # Pecah hotspot closeEvent: penghentian controller sync dipisah agar alur lebih terbaca.
    def _stop_shutdown_sync_controllers(self, *, stop_force: bool, stop_wait_ms: int) -> None:
        if hasattr(self, "sinkron_controller") and self.sinkron_controller:
            LOGGER.debug("Stopping sinkron controller thread...")
            try:
                self.sinkron_controller.stop(force=stop_force, wait_timeout_ms=stop_wait_ms)
                LOGGER.debug("Sinkron controller stopped")
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.warning("Gagal stop sinkron controller: %s", exc)

        if hasattr(self, "sync_flow_bg_controller") and self.sync_flow_bg_controller:
            LOGGER.debug("Stopping background sync flow controller...")
            try:
                self.sync_flow_bg_controller.stop(force=stop_force, wait_timeout_ms=stop_wait_ms)
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.warning("Gagal stop sync_flow_bg_controller: %s", exc)

    # edited by glg
    # Pecah hotspot closeEvent: export-on-close dipisah ke helper use-case.
    def _run_shutdown_export_cycle(self, *, shutdown_cfg, feedback_enabled: bool, wait_message: str) -> None:
        if int(shutdown_cfg.get("export_on_close") or 0) != 1:
            return

        LOGGER.debug("Export on close (AppController) starting...")
        from pypos.modules.sinkronisasi.services.export_cycle_service import ExportCycleService
        from pypos.modules.sinkronisasi.config.sync_config import (
            get_export_on_close_skip_upload_if_offline,
            get_export_on_close_upload_batch_limit,
            get_export_on_close_upload_timeout_sec,
        )
        from pypos.modules.sinkronisasi.services.network_orchestrator_service import (
            NetworkOrchestratorService,
        )

        self._set_shutdown_feedback_progress(50)
        skip_upload = False
        if get_export_on_close_skip_upload_if_offline():
            gate = NetworkOrchestratorService().can_run(
                process=NetworkOrchestratorService.PROCESS_EXPORT,
                force_probe=True,
            )
            skip_upload = not bool(gate.get("allow"))
            if skip_upload:
                LOGGER.info(
                    "[ExportOnClose] Upload ditunda oleh orchestrator: state=%s reason=%s",
                    str(gate.get("state") or "-"),
                    str(gate.get("reason") or "-"),
                )
        upload_limit = get_export_on_close_upload_batch_limit()
        upload_timeout_override = get_export_on_close_upload_timeout_sec()

        def _task_export_on_close(progress_reporter):
            return ExportCycleService().run_cycle(
                source="app_close",
                progress_callback=progress_reporter,
                skip_upload=skip_upload,
                upload_limit=upload_limit,
                upload_timeout_override=upload_timeout_override,
            )

        result = self._run_blocking_task_with_shutdown_feedback(
            _task_export_on_close,
            wait_message if feedback_enabled else "",
            progress_min=50,
            progress_max=95,
        )
        LOGGER.debug(
            "Export on close (AppController) done: rows=%s uploaded=%s ran=%s skipped=%s",
            int((result or {}).get("exported_rows") or 0),
            int((result or {}).get("uploaded") or 0),
            bool((result or {}).get("ran")),
            str((result or {}).get("skipped") or (result or {}).get("skipped_upload") or ""),
        )

    def closeEvent(self, event):
        """
        ✅ ROBUST CLEANUP: Hentikan semua resource sebelum aplikasi ditutup
        + Feedback UI agar user tahu aplikasi sedang memproses shutdown.
        """
        if self._shutdown_in_progress:
            event.accept()
            return
        shutdown_cfg = read_config()
        if self._should_block_close_for_settlement_delivery(shutdown_cfg):
            event.ignore()
            return

        self._shutdown_in_progress = True
        LOGGER.info("Application closing - starting forced cleanup...")

        stop_wait_ms = int(shutdown_cfg.get("sync_stop_wait_timeout_ms") or 2000)
        stop_force = int(shutdown_cfg.get("sync_stop_force_terminate") or 0) == 1
        feedback_enabled = self._is_shutdown_feedback_enabled(shutdown_cfg)
        wait_message = "Mohon tunggu, aplikasi akan tertutup total."

        try:
            QApplication.setOverrideCursor(Qt.WaitCursor)
        except APP_NON_FATAL_EXCEPTIONS:
            pass

        try:
            if feedback_enabled:
                self._show_shutdown_feedback(
                    shutdown_cfg,
                    wait_message,
                )
                self._set_shutdown_feedback_progress(5)

            self._stop_shutdown_timers_and_ui(
                stop_force=stop_force,
                stop_wait_ms=stop_wait_ms,
            )

            if feedback_enabled:
                self._set_shutdown_feedback_progress(25)
                self._pump_shutdown_feedback()

            self._stop_shutdown_sync_controllers(
                stop_force=stop_force,
                stop_wait_ms=stop_wait_ms,
            )

            if feedback_enabled:
                self._set_shutdown_feedback_progress(45)
                self._pump_shutdown_feedback()

            try:
                self._run_shutdown_export_cycle(
                    shutdown_cfg=shutdown_cfg,
                    feedback_enabled=feedback_enabled,
                    wait_message=wait_message,
                )
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.warning("Export on close (AppController) gagal: %s", exc)

            if feedback_enabled:
                self._set_shutdown_feedback_progress(98)
                self._pump_shutdown_feedback()

            # 3. SKIP database close (too slow, OS will handle)
            LOGGER.debug("Skipping database close (OS cleanup)...")

            # 4. SKIP dashboard cleanup (too slow)
            LOGGER.debug("Skipping dashboard cleanup (force quit)...")
            if self.dashboard_window:
                try:
                    self.dashboard_window.blockSignals(True)
                except APP_NON_FATAL_EXCEPTIONS as e:
                    LOGGER.warning("Gagal blockSignals dashboard_window: %s", e)

            # 5. Skip event processing (causes hang)
            LOGGER.debug("Skipping event processing...")

            LOGGER.info("Forced cleanup completed - immediate exit")
        except APP_NON_FATAL_EXCEPTIONS as exc:
            LOGGER.warning("Error during closeEvent cleanup: %s", exc)
        finally:
            if feedback_enabled:
                self._set_shutdown_feedback_progress(100)
                self._set_shutdown_feedback_message(wait_message)
            self._close_shutdown_feedback()
            try:
                QApplication.restoreOverrideCursor()
            except APP_NON_FATAL_EXCEPTIONS:
                pass
            clear_log_context()
            event.accept()
            app_instance = QApplication.instance()
            if app_instance:
                app_instance.quit()
    def server_data_changed(self):
        tables_to_check = get_sync_watch_tables(read_config())
        if not tables_to_check:
            LOGGER.debug("Watch-list sinkron kosong, skip cek update server.")
            return False

        for table in tables_to_check:
            LOGGER.debug("Cek perubahan table: %s", table)
            try:
                if not self.model_sinkron.is_data_updated(table):
                    continue
            except APP_NON_FATAL_EXCEPTIONS as exc:
                LOGGER.warning("Gagal cek perubahan tabel %s: %s", table, exc)
                continue

            LOGGER.warning("Ada perubahan data di server pada tabel: %s", table)
            return True  # Ada perubahan, segera update indikator di dashboard
        LOGGER.debug("Data server up-to-date dengan lokal")
        return False  # Semua data lokal up-to-date

    def mulai_sinkronisasi_background(self):
        LOGGER.info("Sinkronisasi silent background dimulai...")
        if self.sync_flow_bg_controller and self.sync_flow_bg_controller.is_running():
            LOGGER.debug("Background sync flow masih berjalan, skip start baru.")
            return
        self.sync_flow_bg_controller = SyncFlowController(
            mode="login",
            view=None,
            user_info=self.current_user,
            app_controller=self,
        )
        self.sync_flow_bg_controller.sync_finished.connect(self.on_sinkron_selesai_notifikasi)
        self.sync_flow_bg_controller.start()

    def on_sinkron_selesai_notifikasi(self, success=True):
        LOGGER.info("Sinkronisasi selesai (background), success=%s", success)
        if success:
            QMessageBox.information(None, "Sinkronisasi", "Data berhasil disinkronisasi.")
        else:
            QMessageBox.warning(None, "Sinkronisasi", "Sinkronisasi background gagal.")
        self.dashboard_window.update_sync_status(False)
        try:
            if self.dashboard_window and hasattr(self.dashboard_window, "dashboard_controller"):
                self.dashboard_window.dashboard_controller.load_header_logo(force_refresh=True)
        except APP_NON_FATAL_EXCEPTIONS as e:
            LOGGER.warning("Gagal refresh logo header (background sync): %s", e)
        try:
            if self.dashboard_window and hasattr(self.dashboard_window, "printer_settings_controller"):
                self.dashboard_window.printer_settings_controller.refresh_company_logo_assets(force_refresh=True)
        except APP_NON_FATAL_EXCEPTIONS as e:
            LOGGER.warning("Gagal refresh logo company (background sync): %s", e)

if __name__ == "__main__":
    _install_global_exception_handler()
    app = QApplication(sys.argv)
    prune_interval_minutes = get_log_prune_interval_minutes()
    if prune_interval_minutes > 0:
        log_prune_timer = QTimer(app)
        log_prune_timer.timeout.connect(lambda: prune_logs_now())
        log_prune_timer.start(prune_interval_minutes * 60 * 1000)
        app._log_prune_timer = log_prune_timer
    app_lock = _acquire_single_instance_lock()
    if app_lock is None:
        QMessageBox.warning(None, "Aplikasi Sudah Berjalan", "PyPOS sudah berjalan pada jendela lain.")
        sys.exit(0)
    try:
        run_schema_migrations_once(strict=True)
    except APP_NON_FATAL_EXCEPTIONS:
        LOGGER.error("Migrasi schema database gagal saat startup", exc_info=True)
        QMessageBox.critical(
            None,
            "Inisialisasi Database Gagal",
            "Aplikasi gagal menyiapkan database lokal. Silakan hubungi tim teknis.",
        )
        if app_lock and app_lock.isLocked():
            app_lock.unlock()
        sys.exit(1)

    # edited by glg
    # Audit startup read-only untuk mendeteksi transaksi lama yang belum memiliki marker ppn_mode.
    try:
        if is_startup_ppn_mode_audit_enabled():
            sample_limit = get_startup_ppn_mode_audit_sample_limit(default=5)
            PpnModeStartupAuditService(logger=LOGGER).run_startup_audit(sample_limit=sample_limit)
    except APP_NON_FATAL_EXCEPTIONS as audit_exc:
        LOGGER.warning("Audit startup ppn_mode gagal dieksekusi: %s", audit_exc)

    _cfg = read_config()
    # edited by glg
    # Ekstraksi hotspot startup UI ke use-case service agar app.py tetap kecil dan terukur.
    AppRuntimeUiUseCaseService(logger=LOGGER).configure_runtime_ui(
        app=app,
        cfg=_cfg,
    )

    from pypos.core.utils.device_utils import (
        cek_device_ke_server,
        cek_device_terdaftar,
        cek_device_pending,
        simpan_device_lokal,
        hapus_device_lokal,
        get_device_id,
        is_device_not_registered,
        get_active_device_info,
    )
    from pypos.core.utils.path_utils import get_db_path
    from pypos.modules.auth.services.auth_service import AuthService


    from pypos.modules.auth.views.device_registration_dialog import DeviceRegistrationDialog

    # Cek device
    device_id = get_device_id()  # "145862430726560" # sementara saja karena menunggu proses apporoval get_device_id()
    LOGGER.debug("cek device id lokal = %s", device_id)

    # edited by glg
    # Opsi B: fresh install tidak otomatis terikat endpoint seed.
    # Jika device lokal belum ada sama sekali (dan tidak pending), paksa masuk flow registrasi dulu.
    device_local_bootstrap = cek_device_terdaftar(device_id)
    # edited by glg
    # Penanda kondisi awal sebelum dialog registrasi.
    # Dipakai untuk menentukan apakah seed akun karyawan harus strict.
    had_local_device_before_bootstrap = bool(device_local_bootstrap)
    if not device_local_bootstrap:
        device_pending_bootstrap = cek_device_pending(device_id)
        if not device_pending_bootstrap:
            try:
                clear_result = clear_all_known_databases(strict=True)
                LOGGER.info("Database dikosongkan total sebelum registrasi awal: %s", clear_result)
            except APP_NON_FATAL_EXCEPTIONS as e:
                LOGGER.warning("Gagal kosongkan database sebelum registrasi awal: %s", e)
            dialog = DeviceRegistrationDialog()
            if dialog.exec() == QDialog.Accepted:
                # edited by glg
                # Jika setelah dialog device sudah aktif di lokal,
                # lanjut bootstrap ke login tanpa wajib buka ulang aplikasi.
                device_local_after_dialog = cek_device_terdaftar(device_id)
                if not device_local_after_dialog:
                    device_pending_after_dialog = cek_device_pending(device_id)
                    if device_pending_after_dialog:
                        QMessageBox.information(
                            None,
                            "Registrasi",
                            "Registrasi menunggu proses approved. Silakan cek lagi nanti.",
                        )
                        sys.exit(0)
                    QMessageBox.warning(
                        None,
                        "Registrasi",
                        "Registrasi belum selesai. Silakan ulangi proses registrasi.",
                    )
                    sys.exit(0)
            else:
                QMessageBox.warning(None, "Registrasi", "Registrasi dibatalkan.")
                sys.exit(0)

    is_online = AuthService.is_online()
    device_local = None

    if is_online:
        LOGGER.debug("cek device id ke server nya = %s", device_id)
        result = cek_device_ke_server(device_id)

        if result.get('status') == 200:
            device_local = cek_device_terdaftar(device_id)
            data_reg = result.get("dataReg") if isinstance(result, dict) else None
            server_machine_id = data_reg.get("machine_id") if isinstance(data_reg, dict) else None
            if server_machine_id and str(server_machine_id) != str(device_id):
                QMessageBox.critical(None, "Error", "Machine ID dari server tidak sesuai dengan device ini.")
                sys.exit(0)
            hapus_device_lokal(device_id)
            ok_save = simpan_device_lokal(data_reg, device_id=device_id) if isinstance(data_reg, dict) else False
            if not ok_save:
                QMessageBox.critical(None, "Error", "Gagal menyimpan data device lokal dari server.")
                sys.exit(0)
            if device_local and had_local_device_before_bootstrap:
                bootstrap_if_missing(assume_installed=True)
            else:
                set_bool("first_install_done", True)
                set_bool("first_sync_pending", True)
                _employee_seed_use_case_service.mark_seed_pending()
            try:
                sync_info = _ensure_per_employee_seed(
                    device_id=device_id,
                    strict=not bool(had_local_device_before_bootstrap),
                )
                LOGGER.info(
                    "Seed per_employee saat bootstrap: seeded=%s rows_synced=%s local_rows=%s",
                    bool(sync_info.get("seeded")),
                    int(sync_info.get("rows_synced") or 0),
                    int(sync_info.get("local_rows") or 0),
                )
            except APP_NON_FATAL_EXCEPTIONS as sync_exc:
                QMessageBox.critical(
                    None,
                    "Sinkronisasi Akun Gagal",
                    _format_employee_seed_error_message(sync_exc),
                )
                sys.exit(0)
        elif result.get('status') == 202:
            QMessageBox.information(None, "Registrasi", "Registrasi menunggu proses approved. Silakan Cek Lagi Lain Kali.")
            sys.exit(0)
        elif result.get('status') == 404 or is_device_not_registered(result):
            try:
                clear_result = clear_all_known_databases(strict=True)
                LOGGER.info("Database dikosongkan total (master+transaksional): %s", clear_result)
            except APP_NON_FATAL_EXCEPTIONS as e:
                LOGGER.warning("Gagal kosongkan database: %s", e)
                QMessageBox.critical(None, "Error", "Reset database gagal. Hubungi teknisi.")
                sys.exit(0)
            dialog = DeviceRegistrationDialog()
            if dialog.exec() == QDialog.Accepted:
                # edited by glg
                # Registrasi bisa menghasilkan status "sudah terdaftar".
                # Jika device aktif sudah tersimpan lokal, lanjut login sekarang.
                device_local_after_dialog = cek_device_terdaftar(device_id)
                if not device_local_after_dialog:
                    device_pending_after_dialog = cek_device_pending(device_id)
                    if device_pending_after_dialog:
                        QMessageBox.information(
                            None,
                            "Registrasi",
                            "Registrasi menunggu proses approved. Silakan cek lagi nanti.",
                        )
                        sys.exit(0)
                    QMessageBox.warning(
                        None,
                        "Registrasi",
                        "Registrasi belum selesai. Silakan ulangi proses registrasi.",
                    )
                    sys.exit(0)
            else:
                QMessageBox.warning(None, "Registrasi", "Registrasi dibatalkan.")
                sys.exit(0)
        else:
            raw_reason = result.get('reason', 'Unknown error') if isinstance(result, dict) else "Unknown error"
            LOGGER.warning("Device check failed (bootstrap): %s", raw_reason)
            safe_reason, _ = sanitize_ui_message("critical", str(raw_reason))
            QMessageBox.critical(None, "Error", f"Pemeriksaan device gagal.\n{safe_reason}")
            sys.exit(0)
    else:
        # Offline: hanya cek ke DB lokal
        device_local = cek_device_terdaftar(device_id)
        if device_local:
            LOGGER.info("Device terdaftar di lokal, lanjut login")
            bootstrap_if_missing(assume_installed=True)
        else:
            device_pending = cek_device_pending(device_id)
            if device_pending:
                QMessageBox.information(None, "Registrasi",
                                        "Registrasi device masih pending.\n"
                                        "Silakan online untuk proses approval.")
                sys.exit(0)
            QMessageBox.warning(None, "Offline",
                                "Koneksi internet tidak tersedia.\n"
                                "Device belum terdaftar di lokal. Silakan online untuk registrasi.")
            sys.exit(0)

    LOGGER.info(
        "Pre-login sync per_employee dinonaktifkan. "
        "Seed akun dilakukan saat device approved."
    )

    controller = AppController()
    controller.show()

    # âœ… Run application dengan proper exit code
    exit_code = app.exec()
    if app_lock and app_lock.isLocked():
        app_lock.unlock()

    sys.exit(exit_code)
