import json
import os
import hashlib
import shutil
import sys
import logging
import threading
import tempfile
from urllib.parse import urlparse, urlunparse
from pypos.core.utils.path_utils import get_install_base_dir, get_app_data_dir, get_app_resource_dir

# edited by glg

LOGGER = logging.getLogger(__name__)

APP_DATA_DIR = get_app_data_dir()
CONFIG_FILE = os.path.join(APP_DATA_DIR, "config.json")
APP_SETTINGS_FILE = os.path.join(APP_DATA_DIR, "app_settings.json")
_LEGACY_CONFIG_FILE = os.path.join(get_install_base_dir(), "config.json")
_LEGACY_APP_SETTINGS_FILE = os.path.join(get_install_base_dir(), "app_settings.json")

_ENDPOINT_CACHE = None
_APP_SETTINGS_CACHE = None
_MERGED_CACHE = None
# edited by glg
# Lindungi cache konfigurasi dari race pada akses multi-thread.
_CONFIG_CACHE_LOCK = threading.RLock()
# edited by glg
# Hindari spam warning konfigurasi JWT lemah pada pemanggilan berulang.
_JWT_OPTIONAL_WARNED = False

_NUMERIC_PARSE_EXCEPTIONS = (TypeError, ValueError)
_JSON_READ_EXCEPTIONS = (OSError, ValueError, TypeError, json.JSONDecodeError)

_ENDPOINT_CONFIG_DEFAULTS = {
    "api_base_url": "",
    "request_timeout": 3,
    "http_max_retry": 3,
    "http_backoff_sec": 1,
    "http_backoff_factor": 2.0,
    "http_backoff_max_sec": 30,
    "http_backoff_jitter_ratio": 0.2,
    "http_retry_statuses": [408, 429, 500, 502, 503, 504],
    "ep_datas": "/eusvc/DataSync/doSync_auto",
    "ep_branch_lookup": "/eusvc/DataSync/publicBranchLookup",
    "ep_device": "/eusvc/NonRest/postDeviceRegistrasi",
    "ep_device_cek": "/eusvc/NonRest/checkDevRegisV2",
    "ep_update_cek": "/eusvc/NonRest/server_CheckUpdate",
    "ep_server_sync": "/eusvc/DataSync/serverSync",
    # edited by glg
    # Endpoint bootstrap non-JWT untuk seed awal akun karyawan (opsional).
    "ep_seed_per_employee_bootstrap": "",
    "ep_upload_stream": "/eusvc/NonRest/setUploadStream",
    "ep_upload_compile_status": "/eusvc/NonRest/getUploadCompileStatus",
    "ep_jwt_issue": "/eusvc/NonRest/issueJwt",
    "ep_jwt_refresh": "/eusvc/NonRest/refreshJwt",
    "ep_ui_assets": "/eusvc/DataSync/uiAssets",
    "ep_diskon_save_free_produk": "/eusvc/ProDiskon/saveFreeProduk",
    "ep_diskon_check_free_produk": "/eusvc/ProDiskon/checkFreeProdukQuota",
    # edited by glg
    # Endpoint integrasi pre-order (sales support -> kasir POS).
    "ep_preorder_get": "/eusvc/NonRest/get_preorder",
    "ep_preorder_use": "/eusvc/NonRest/use_preorder",
    # edited by glg
    # Endpoint pengaturan pajak POS dari server (opsional, future-ready).
    # Jika kosong, POS fallback ke konfigurasi lokal.
    "ep_ppn_settings": "",
    # edited by glg
    # Endpoint direct push settlement (opsional).
    # Bila kosong, settlement tetap lewat pipeline export/upload existing.
    "ep_settlement_direct": "",
    "ep_ip_geolocate": "https://ipinfo.io/json",
    "ep_receipt_qr": "",
}


_APP_SETTINGS_DEFAULTS = {
    "mode": 1,
    "client_name": "Default Client",
    "db_path": "db/beta_sb_pos_sqlite.db",
    "export_json_dir": "exports/transactions",
    "export_json_interval_minutes": 5,
    "export_batch_limit": 5000,
    "export_schema_version": "1.0.0",
    # edited by glg
    # Profil payload export:
    # - legacy_strict: isi file format lama (array transaksi lengkap items/free_items/payment).
    # - modern: top-level modern dengan schema_version/server_hash/base_url.
    "export_payload_profile": "legacy_strict",
    "export_compression_codec": "xz",
    "export_xz_preset": 6,
    "export_transaksi_data_by_transaksi_ids": 1,
    "export_tables": [
        "transaksi",
        "transaksi_data",
        "settlement_history",
        "transaksi_settlement",
    ],
    # edited by glg
    # Toggle granular export tabel transaksi.
    # - Jika transaksi OFF -> transaksi_data + transaksi_data_registry otomatis OFF (dependency guard).
    # - Jika transaksi_data OFF -> transaksi_data_registry otomatis OFF.
    "export_transaksi_enabled": 1,
    "export_transaksi_data_enabled": 1,
    "export_transaksi_data_registry_enabled": 1,
    "export_cabang_code_source": "cabang_id",
    "export_sqlite_busy_timeout_ms": 5000,
    "export_sqlite_use_wal": 1,
    "export_allow_cursor_reset": 1,
    "export_upload_enabled": 1,
    "export_upload_batch_limit": 50,
    "export_upload_timeout_sec": 30,
    "export_upload_retry_max_attempt": 8,
    "export_upload_retry_backoff_base_sec": 2,
    "export_upload_retry_backoff_factor": 2,
    "export_upload_retry_backoff_max_sec": 300,
    # edited by glg
    # Retensi file export lokal (APPDATA/exports) setelah upload sukses.
    # Key baru dipisah agar lebih jelas dari konteks retry/upload.
    "export_file_retention_days": 7,
    # Key lama tetap dipertahankan untuk backward-compatibility.
    "export_upload_file_retention_days": 7,
    "export_flux_empty_retention_days": 7,
    "export_flux_empty_cleanup_limit": 500,
    "export_upload_retryable_reasons": [
        "timeout",
        "timed out",
        "tempor",
        "server busy",
        "try again",
        "retry",
        "locked",
        "deadlock",
        "too many requests",
    ],
    "export_upload_compile_check_enabled": 1,
    "export_upload_compile_poll_max_attempt": 3,
    "export_upload_compile_poll_interval_sec": 2,
    "export_upload_compile_pending_stuck_attempt_threshold": 6,
    "export_upload_compile_pending_stuck_age_minutes": 30,
    "export_upload_compile_required_flags": [
        "compiled_transaksi",
        "compiled_data",
        "compiled_registry",
    ],
    "export_columns_transaksi": [],
    "export_columns_transaksi_data": [],
    "export_on_close": 1,
    # edited by glg
    # Kebijakan shutdown export:
    # - skip upload ketika offline agar close tidak lama.
    # - batasi upload saat close untuk mencegah hang pada jaringan buruk.
    "export_on_close_skip_upload_if_offline": 1,
    "export_on_close_online_probe_timeout_sec": 1.0,
    "export_on_close_upload_batch_limit": 5,
    "export_on_close_upload_timeout_sec": 5,
    # edited by glg
    # Trigger export tambahan saat settlement sukses (tanpa menonaktifkan timer 5 menit).
    "export_on_settlement": 1,
    # edited by glg
    # Kebijakan pengiriman settlement:
    # - dual: settlement tetap masuk pipeline export + boleh direct push.
    # - direct_only: settlement hanya direct push (aktif jika settlement_direct_enabled=1).
    # - export_only: settlement hanya via pipeline export/upload file (direct di-skip).
    "settlement_delivery_mode": "direct_only",
    # edited by glg
    # Direct push settlement ke endpoint khusus (opsional, non-blocking).
    # Jika endpoint belum siap, biarkan OFF agar tetap aman di pipeline export existing.
    "settlement_direct_enabled": 0,
    "settlement_direct_timeout_sec": 8,
    "settlement_direct_retry_max_attempt": 2,
    "settlement_direct_retry_backoff_sec": 1.0,
    "settlement_direct_auth_required": 1,
    # edited by glg
    # Guard shutdown ketika direct settlement belum terkirim.
    "settlement_close_guard_enabled": 1,
    "settlement_close_guard_retry_before_block": 1,
    "settlement_close_guard_retry_max_items": 3,
    "settlement_close_guard_title": "Penutupan Ditunda",
    # edited by glg
    # Replay backlog upload sesegera mungkin setelah login/startup dashboard.
    "export_replay_on_startup_enabled": 1,
    "export_replay_on_startup_delay_ms": 1500,
    "export_replay_on_startup_require_online": 1,
    "export_replay_on_startup_upload_batch_limit": 25,
    # edited by glg
    # Recovery row FAILED untuk error transient (timeout/offline) saat startup replay.
    "export_requeue_failed_transient_on_startup": 1,
    "export_requeue_failed_transient_limit": 100,
    "export_requeue_failed_transient_max_age_hours": 72,
    # edited by glg
    # Policy merge whitelist untuk update:
    # - tidak override seluruh app_settings
    # - hanya key kritikal yang diseragamkan lintas device.
    "managed_settings_policy_enabled": 0,
    "managed_settings_policy_version": 0,
    "managed_settings_policy_keys": [
        "settlement_delivery_mode",
        "settlement_direct_enabled",
        "export_on_settlement",
        "export_transaksi_enabled",
        "export_transaksi_data_enabled",
        "export_transaksi_data_registry_enabled",
    ],
    "managed_settings_policy_applied_version": 0,
    "auto_sync_master_minutes": 5,
    "auto_sync_start_delay_seconds": 10,
    # edited by glg
    # Probe update ringan saat notify_only (detik).
    # Nilai ini tidak menjalankan sinkron apply otomatis.
    "auto_sync_probe_interval_seconds": 60,
    # edited by glg
    # Orchestrator jaringan terpusat untuk operasi:
    # - sync
    # - export/upload
    # - settlement direct
    "network_orchestrator_enabled": 1,
    "network_orchestrator_probe_cache_ttl_sec": 2.0,
    "network_orchestrator_fail_threshold": 3,
    "network_orchestrator_recover_threshold": 2,
    "network_orchestrator_grace_seconds": 45,
    "network_orchestrator_allow_unstable_operations": 1,
    "network_orchestrator_allow_degraded_without_server": 0,
    "network_orchestrator_allow_internet_only_sync": 0,
    "network_orchestrator_allow_internet_only_export": 0,
    "network_orchestrator_allow_internet_only_settlement_direct": 0,
    "network_orchestrator_server_probe_timeout_sec": 1.0,
    "network_orchestrator_internet_probe_timeout_sec": 0.8,
    # edited by glg
    # P2 platform-scale: aktifkan policy backpressure berbasis metrik event outbox.
    "event_outbox_backpressure_enabled": 1,
    "event_outbox_backpressure_pending_warn": 2500,
    "event_outbox_backpressure_pending_critical": 10000,
    "event_outbox_backpressure_error_warn_pct": 2.0,
    "event_outbox_backpressure_error_critical_pct": 8.0,
    "event_outbox_backpressure_latency_warn_ms": 800.0,
    "event_outbox_backpressure_latency_critical_ms": 2000.0,
    "event_outbox_backpressure_default_batch": 250,
    # edited by glg
    # Floor batch adaptif untuk node lemah; menggantikan hardcoded floor=50.
    "event_outbox_backpressure_min_batch": 10,
    # edited by glg
    # Runtime event-first orchestration:
    # - trigger export lewat gateway + dedup
    # - eksekusi via lifecycle outbox terpusat.
    "event_ingestion_runtime_enabled": 1,
    "event_outbox_runtime_enabled": 1,
    "event_ingestion_export_trigger_dedup_window_sec": 5,
    "event_outbox_runtime_claim_limit": 100,
    "event_outbox_runtime_lease_seconds": 60,
    "event_outbox_runtime_max_inflight": 1000,
    "event_outbox_runtime_retry_delay_seconds": 30,
    "event_outbox_runtime_purge_sent_days": 7,
    "event_outbox_runtime_purge_limit": 2000,
    "event_outbox_runtime_release_expired_each_cycle": 1,
    # edited by glg
    # Runtime health-gated rollout guard untuk operasi export lintas cabang.
    "fleet_rollout_runtime_guard_enabled": 1,
    "fleet_rollout_runtime_fail_open": 1,
    "fleet_rollout_runtime_halt_on_unhealthy": 1,
    "fleet_rollout_runtime_config_version": "runtime-local",
    "fleet_rollout_runtime_wave_name": "canary",
    "fleet_rollout_runtime_canary_percent": 1,
    "fleet_rollout_runtime_wave_percents": [5, 20, 50, 100],
    "fleet_rollout_runtime_branch_ids": [],
    "fleet_rollout_runtime_fail_threshold_pct": 2.0,
    "fleet_rollout_runtime_latency_threshold_ms": 2000.0,
    "fleet_rollout_runtime_min_sample_count": 0,
    "fleet_rollout_runtime_fail_when_sample_insufficient": 0,
    "sync_stop_wait_timeout_ms": 2000,
    "sync_cleanup_wait_timeout_ms": 1000,
    "sync_stop_force_terminate": 0,
    # edited by glg
    # Feedback UI saat shutdown agar user tahu aplikasi masih memproses cleanup/export.
    "shutdown_feedback_enabled": 1,
    "shutdown_feedback_title": "Menutup Aplikasi",
    "shutdown_feedback_initial_message": "Mohon tunggu, aplikasi akan tertutup total.",
    "sync_circuit_breaker_enabled": 1,
    "sync_circuit_failure_threshold": 3,
    "sync_circuit_open_seconds": 120,
    "export_retry_backoff_base_sec": 2,
    "export_retry_backoff_factor": 2,
    "export_retry_backoff_max_sec": 300,
    "export_retry_max_attempt": 8,
    "voucher_enabled_for_kasir": 0,
    "ppn_percent_default": 11,
    "ppn_percent": 11,
    # edited by glg
    # Default mode pajak transaksi saat belum ada aturan dari server.
    "ppn_mode": "include",
    # edited by glg
    # Audit startup read-only untuk mendeteksi transaksi legacy yang belum punya marker ppn_mode.
    "startup_ppn_mode_audit_enabled": 1,
    "startup_ppn_mode_audit_sample_limit": 5,
    "log_level": "INFO",
    "log_console": 1,
    "log_console_level": "INFO",
    "log_to_file": 1,
    "log_file_level": "INFO",
    "log_dir": "logs",
    "log_file_name": "pypos.log",
    "log_file_max_bytes": 5242880,
    "log_file_backup_count": 5,
    "log_context_enabled": 1,
    "log_context_fields": ["user_id", "machine_id", "cabang_id"],
    "log_redact_enabled": 1,
    "log_redact_keys": [
        "password", "passwd", "token", "authorization", "api_key", "pin",
        "kode_voucher", "voucher_code", "voucher"
    ],
    "log_file_prune_on_start": 1,
    "log_file_retention_days": 30,
    "log_prune_interval_minutes": 1440,
    "log_format": "%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    "log_date_format": "%Y-%m-%d %H:%M:%S",
    "print_demo_nomor": "INV-DEMO-001",
    "print_demo_kasir_nama": "Kasir Demo",
    "print_demo_customer_nama": "Customer Demo",
    "print_demo_wifi_code": "WIFI-DEMO",
    "print_demo_payment_method": "Debit Card",
    "print_demo_card_brand": "BCA",
    "print_demo_card_last4": "1234",
    "print_demo_approval_code": "APPROVED",
    "print_demo_transaksi_bulat": 100000,
    "print_demo_ppn_persen": 11,
    "print_demo_diskon_persen": 5,
    "print_demo_transaksi_nilai": 95000,
    "printer_default_paper_size": "58mm",
    "print_demo_items": [
        {"name": "Produk Demo A", "qty": 2, "price": 4500, "disc": 1000, "free": 0},
        {"name": "Produk Demo B", "qty": 5, "price": 3000, "disc": 0, "free": 0},
        {"name": "Produk Demo C", "qty": 1, "price": 7000, "disc": 0, "free": 1}
    ],
    "qt_platform": "windows",
    "window_pos_x": 5,
    "window_pos_y": 5,
    "ui_base_width": 1900,
    "ui_base_height": 1000,
    "ui_scale_max": 1.0,
    # edited by glg
    # Guard skala UI global:
    # - ui_scale_min: batas bawah agar komponen tidak terlalu kecil.
    # - ui_scale_override: paksa skala tertentu (0 = auto).
    "ui_scale_min": 0.75,
    "ui_scale_override": 0,
    # edited by glg
    # Audit kesiapan aset gambar untuk DPI tinggi:
    # - cek varian SVG/@2x/@3x saat startup dan tulis ringkasan ke log.
    "ui_image_asset_audit_on_startup": 1,
    # edited by glg
    # Dialog login dibuat sedikit lebih besar agar teks status jaringan dan kontrol form tidak terpotong.
    "login_window_scale": 0.34,
    "login_window_min_size": 460,
    "local_online_host": "192.168.1.1",
    "local_online_port": 80,
    "local_online_timeout": 1,
    # edited by glg
    # Driver koneksi SQLite terpusat.
    # - sqlite3: default saat ini
    # - pysqlcipher3/sqlcipher: kesiapan tahap enkripsi file DB.
    "sqlite_driver": "sqlite3",
    "sqlite_timeout": 30,
    "sqlite_busy_timeout_ms": 5000,
    # edited by glg
    # Read path UI dibuat fail-fast agar klik/hover tidak menunggu lock terlalu lama.
    "sqlite_ui_read_timeout_sec": 1,
    "sqlite_ui_read_busy_timeout_ms": 350,
    # edited by glg
    # Guard SQLCipher (non-aktif default):
    # key wajib via environment variable untuk menghindari plaintext key di file config.
    "sqlite_cipher_enabled": 0,
    "sqlite_cipher_key_env": "PYPOS_SQLCIPHER_KEY",
    "sqlite_cipher_compatibility": 4,
    "sqlite_cipher_kdf_iter": 256000,
    "sqlite_cipher_page_size": 4096,
    # edited by glg
    # Guard global untuk SQL identifier dinamis.
    # Saat ON, identifier wajib lolos pola aman [A-Za-z_][A-Za-z0-9_]*.
    "sql_identifier_strict_mode": 1,
    "sqlite_use_wal": 1,
    "sqlite_foreign_keys": 1,
    # edited by glg
    # Guard operasi maintenance destruktif database:
    # - default fail-closed: disabled sampai ada approval eksplisit.
    # - allow_non_strict tetap OFF agar operasi destruktif wajib jalur strict.
    "maintenance_destructive_db_ops_enabled": 0,
    "maintenance_destructive_db_ops_allow_non_strict": 0,
    "maintenance_destructive_db_ops_reason": "",
    "login_layout_spacing": 10,
    "login_layout_margin": 20,
    "login_toggle_btn_width": 40,
    "login_toggle_btn_height": 35,
    # edited by glg
    # Hardening login policy berbasis status jaringan:
    # - Prioritas cek server POS.
    # - Fallback cek internet publik agar bisa bedakan "server down" vs "internet putus".
    # - Semua tetap backward-compatible via feature flag.
    "login_network_policy_enabled": 1,
    "login_require_online": 1,
    "login_show_startup_online_notice": 1,
    "login_network_disable_button_when_blocked": 1,
    "login_network_recheck_interval_ms": 3000,
    "login_server_probe_timeout_sec": 0.8,
    "login_internet_probe_timeout_sec": 0.8,
    "login_network_fail_threshold": 3,
    "login_network_recover_threshold": 2,
    "login_network_grace_seconds": 45,
    "login_allow_when_server_down_but_internet_up": 1,
    "login_allow_when_network_unstable": 1,
    "login_offline_emergency_admin_enabled": 0,
    "login_internet_probe_urls": [
        "https://www.google.com/generate_204",
        "https://one.one.one.one/cdn-cgi/trace",
    ],
    # edited by glg
    # Future-ready: kontrak endpoint health server (belum wajib dipakai saat ini).
    "login_server_health_check_enabled": 0,
    "login_server_health_endpoint": "/eusvc/Health/posNetworkStatus",
    "login_server_health_timeout_sec": 1.0,
    "device_reg_min_width": 480,
    "device_reg_max_width": 800,
    "device_reg_ket_height": 80,
    "device_reg_notify_timeout": 5000,
    "device_reg_notify_extra_delay": 500,
    "customer_search_dialog_width": 900,
    "customer_search_dialog_height": 600,
    "customer_search_page_size": 100,
    "dashboard_logo_size": 80,
    "dashboard_logo_width": 150,
    "dashboard_sidebar_width": 200,
    "dashboard_sidebar_btn_height": 36,
    "dashboard_sidebar_spacing": 2,
    "dashboard_sidebar_margin": 4,
    "dashboard_status_icon_box": 28,
    "dashboard_status_icon_size": 20,
    # edited by glg
    "dashboard_online_poll_interval_ms": 30000,
    "dashboard_online_probe_timeout_sec": 1,
    # edited by glg
    # Poll scanner pending barcode di UI. Jeda terlalu kecil membuat event loop penuh.
    "scanner_poll_interval_ms": 120,
    # edited by glg
    # Poll status printer diperlambat agar probing OS/WMI tidak menghambat UI.
    "printer_status_poll_interval_ms": 10000,
    "printer_status_wmi_probe_every": 3,
    # edited by glg
    # Refresh ringkasan dashboard (query DB) default 15 detik.
    "dashboard_info_refresh_interval_ms": 15000,
    # edited by glg
    # Wrapper visual tombol generik agar tombol non-class tidak tampil seperti teks biasa.
    "ui_button_wrapper_enabled": 1,
    "ui_button_wrapper_bg": "#e9eef5",
    "ui_button_wrapper_bg_hover": "#dde7f3",
    "ui_button_wrapper_bg_pressed": "#d2deec",
    "ui_button_wrapper_border": "#95a8c2",
    "ui_button_wrapper_border_hover": "#7f96b5",
    "ui_button_wrapper_text": "#1f2d3d",
    "ui_button_wrapper_radius_px": 4,
    "ui_button_wrapper_padding_v_px": 4,
    "ui_button_wrapper_padding_h_px": 12,
    "scanner_detector_enabled": 0,
    "scanner_max_inter_char_ms": 50,
    "scanner_min_length": 5,
    "scanner_require_enter_suffix": 1,
    "scanner_duplicate_guard_ms": 180,
    "scanner_backend_mode": "wedge",
    "scanner_rawinput_enabled": 1,
    "scanner_rawinput_whitelist": [],
    "jwt_enabled": 1,
    "jwt_required": 0,
    "jwt_auto_refresh": 1,
    "jwt_refresh_skew_seconds": 60,
    "jwt_attach_internal_only": 1,
    "pembatalan_allowed_days": 0,
    "return_allowed_days": 0,
    "pembatalan_search_limit": 200,
    "return_search_limit": 200,
    # edited by glg
    # Batas hasil query UI untuk mencegah render sangat besar yang memicu lag.
    "history_transaksi_max_rows": 1200,
    "load_transaksi_max_rows": 800,
    "settlement_history_max_rows": 1200,
    "autocomplete_limit": 50,
    "autocomplete_min_keyword": 1,
    "autocomplete_debounce_ms": 220,
    # edited by glg
    # Ambang qty besar yang mewajibkan verifikasi admin settlement per-produk.
    "admin_qty_verify_threshold": 99,
    "performance_profile_enabled": 0,
    # edited by glg
    # Guard lookup penjualan: hanya tampilkan produk yang punya harga_list aktif > 0.
    "lookup_require_active_harga": 1,
    # edited by glg
    # Tabel sync cepat untuk kasus harga belum diatur di POS.
    "quick_price_sync_tables": ["price"],
    # edited by glg
    # Fitur UI: tombol print ulang nota settlement pada section history settlement.
    "settlement_history_reprint_enabled": 1,
    "settlement_history_reprint_button_label": "Print Ulang Nota Settlement",
    "sync_cabang_global_ids": [0, -1],
    "sync_cabang_allow_null_global": 1,
    "sync_tables": [
        "per_cabang_device", "per_cabang", "per_customers", "per_customer_level",
        "per_employee", "bank", "produk", "produk_folders", "price",
        "price_last_purchase", "price_per_area", "satuan", "satuan_produk_relasi",
        "diskon", "diskon_customer", "company_profile", "setting_struk",
        "fifo_avg", "_rek_pembantu_customer_cache", "__rek_pembantu_customer__2010050"
    ],
    "sync_watch_tables": [
        "produk",
        "price",
        "price_per_area",
        "diskon",
        "diskon_customer",
        "per_customers",
        "per_employee",
        "setting_struk",
    ],
    "master_tables": [
        "per_cabang_device", "per_cabang", "per_customers", "per_customer_level",
        "per_employee", "bank", "produk", "produk_folders", "price",
        "price_last_purchase", "price_per_area", "satuan", "satuan_produk_relasi",
        "diskon", "diskon_customer", "company_profile", "setting_struk",
        "fifo_avg", "_rek_pembantu_customer_cache", "__rek_pembantu_customer__2010050"
    ],
}


# edited by glg
def _get_runtime_managed_policy_file():
    return os.path.join(os.path.dirname(APP_SETTINGS_FILE), "app_settings.managed_policy.json")


# edited by glg
def _is_frozen_runtime():
    if getattr(sys, "frozen", False):
        return True
    return bool(getattr(sys, "_MEIPASS", None))


# edited by glg
def _normalize_policy_keys(raw_keys):
    if isinstance(raw_keys, str):
        items = [item.strip() for item in str(raw_keys).split(",")]
    elif isinstance(raw_keys, (list, tuple, set)):
        items = [str(item).strip() for item in raw_keys]
    else:
        items = []
    normalized = []
    seen = set()
    for key in items:
        if not key or key in seen:
            continue
        seen.add(key)
        normalized.append(key)
    return normalized


# edited by glg
def _parse_int(value, default=0):
    try:
        return int(value)
    except _NUMERIC_PARSE_EXCEPTIONS:
        return int(default)


# edited by glg
def _parse_float(value, default=0.0):
    try:
        return float(value)
    except _NUMERIC_PARSE_EXCEPTIONS:
        return float(default)


# edited by glg
def _resolve_managed_policy_payload():
    runtime_policy_path = _get_runtime_managed_policy_file()
    if os.path.exists(runtime_policy_path):
        try:
            raw = _read_json_file(runtime_policy_path)
        except _JSON_READ_EXCEPTIONS as e:
            LOGGER.warning("[CONFIG_POLICY] Runtime policy tidak valid: %s", e)
            raw = {}
        values = raw.get("values") if isinstance(raw.get("values"), dict) else {}
        enabled_raw = raw.get("enabled", raw.get("policy_enabled", 1))
        enabled = _parse_int(enabled_raw, default=int(bool(enabled_raw))) == 1
        version = _parse_int(raw.get("version", raw.get("policy_version", 0)) or 0, default=0)
        keys = _normalize_policy_keys(raw.get("managed_keys"))
        if not keys:
            keys = _normalize_policy_keys(_APP_SETTINGS_DEFAULTS.get("managed_settings_policy_keys"))
        return {
            "source": runtime_policy_path,
            "enabled": enabled,
            "version": max(0, int(version)),
            "keys": keys,
            "values": dict(values or {}),
        }

    if not _is_frozen_runtime():
        return {
            "source": "",
            "enabled": False,
            "version": 0,
            "keys": [],
            "values": {},
        }

    for src in _get_app_settings_seed_candidates():
        if not src:
            continue
        if os.path.abspath(src) == os.path.abspath(APP_SETTINGS_FILE):
            continue
        if not os.path.exists(src):
            continue
        try:
            raw = _read_json_file(src)
        except _JSON_READ_EXCEPTIONS:
            continue
        _, seed_app = _split_config_keys(raw)
        if not isinstance(seed_app, dict) or not seed_app:
            continue
        enabled_raw = seed_app.get("managed_settings_policy_enabled", 0)
        enabled = _parse_int(enabled_raw, default=int(bool(enabled_raw))) == 1
        if not enabled:
            continue
        version = _parse_int(seed_app.get("managed_settings_policy_version", 0) or 0, default=0)
        keys = _normalize_policy_keys(seed_app.get("managed_settings_policy_keys"))
        values = {}
        for key in keys:
            if key in seed_app:
                values[key] = seed_app.get(key)
        return {
            "source": src,
            "enabled": True,
            "version": max(0, int(version)),
            "keys": keys,
            "values": values,
        }

    return {
        "source": "",
        "enabled": False,
        "version": 0,
        "keys": [],
        "values": {},
    }


# edited by glg
def _backup_app_settings_before_policy(current_payload):
    backup_path = APP_SETTINGS_FILE + ".backup_policy"
    try:
        _write_json_file(backup_path, dict(current_payload or {}))
        return backup_path
    except (OSError, TypeError, ValueError):
        return ""


# edited by glg
def _apply_managed_app_settings_policy_if_needed():
    if not os.path.exists(APP_SETTINGS_FILE):
        return
    policy = _resolve_managed_policy_payload()
    if not bool(policy.get("enabled")):
        return
    keys = _normalize_policy_keys(policy.get("keys"))
    if not keys:
        return
    values = policy.get("values") if isinstance(policy.get("values"), dict) else {}
    desired = {}
    for key in keys:
        if key in values:
            desired[key] = values.get(key)
    if not desired:
        return

    current_payload = _read_json_file(APP_SETTINGS_FILE)
    _, current_app = _split_config_keys(current_payload)
    applied_version = _parse_int(current_app.get("managed_settings_policy_applied_version", 0) or 0, default=0)
    policy_version = _parse_int(policy.get("version") or 0, default=0)

    has_drift = any(current_app.get(key) != val for key, val in desired.items())
    if not has_drift and policy_version <= applied_version:
        return

    _backup_app_settings_before_policy(current_payload)
    merged = dict(current_payload)
    merged.update(desired)
    merged["managed_settings_policy_applied_version"] = int(max(policy_version, applied_version))
    merged["managed_settings_policy_version"] = int(policy_version)
    merged["managed_settings_policy_enabled"] = 1
    merged["managed_settings_policy_keys"] = list(keys)
    _write_json_file(APP_SETTINGS_FILE, merged)
    LOGGER.info(
        "[CONFIG_POLICY] applied source=%s version=%s keys=%s drift=%s",
        str(policy.get("source") or "-"),
        int(policy_version),
        ",".join(keys),
        int(bool(has_drift)),
    )


def _read_json_file(path):
    if not os.path.exists(path):
        return {}
    with open(path, "r", encoding="utf-8") as f:
        data = json.load(f)
    if not isinstance(data, dict):
        raise ValueError(f"Format file JSON tidak valid: {path}")
    return data


def _get_config_seed_candidates():
    # edited by glg
    # PyInstaller onedir (v6+) menaruh data di folder _internal.
    install_base = get_install_base_dir()
    resource_base = get_app_resource_dir()
    return [
        os.path.join(install_base, "config.json"),
        os.path.join(install_base, "_internal", "config.json"),
        os.path.join(resource_base, "config.json"),
        os.path.join(resource_base, "_internal", "config.json"),
        _LEGACY_CONFIG_FILE,
    ]


def _get_app_settings_seed_candidates():
    install_base = get_install_base_dir()
    resource_base = get_app_resource_dir()
    return [
        os.path.join(install_base, "app_settings.json"),
        os.path.join(install_base, "_internal", "app_settings.json"),
        os.path.join(resource_base, "app_settings.json"),
        os.path.join(resource_base, "_internal", "app_settings.json"),
        _LEGACY_APP_SETTINGS_FILE,
    ]


def _copy_first_existing_file(target_path, candidates):
    for src in candidates:
        if not src:
            continue
        if os.path.abspath(src) == os.path.abspath(target_path):
            continue
        if os.path.exists(src):
            os.makedirs(os.path.dirname(target_path), exist_ok=True)
            shutil.copy2(src, target_path)
            return True
    return False


def _load_seed_endpoint_with_base_url():
    for src in _get_config_seed_candidates():
        if not src:
            continue
        if os.path.abspath(src) == os.path.abspath(CONFIG_FILE):
            continue
        if not os.path.exists(src):
            continue
        try:
            payload = _read_json_file(src)
            endpoint_cfg, _ = _split_config_keys(payload)
            if str(endpoint_cfg.get("api_base_url") or "").strip():
                return endpoint_cfg
        except _JSON_READ_EXCEPTIONS:
            continue
    return None


def _write_json_file(path, payload):
    # edited by glg
    # Atomic write: tulis ke file sementara lalu replace, untuk hindari file config korup
    # saat proses crash/force close/power loss.
    dir_path = os.path.dirname(path) or "."
    os.makedirs(dir_path, exist_ok=True)
    fd, tmp_path = tempfile.mkstemp(prefix=".tmp_cfg_", suffix=".json", dir=dir_path)
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(payload, f, indent=4, ensure_ascii=False)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp_path, path)
    finally:
        if os.path.exists(tmp_path):
            try:
                os.remove(tmp_path)
            except OSError:
                pass


def _is_endpoint_key(key: str) -> bool:
    return key in _ENDPOINT_CONFIG_DEFAULTS


def _split_config_keys(payload: dict):
    endpoint = {}
    app_settings = {}
    for key, value in (payload or {}).items():
        if _is_endpoint_key(key):
            endpoint[key] = value
        else:
            app_settings[key] = value
    return endpoint, app_settings


def _validate_endpoint_config(config: dict, require_base_url: bool = True):
    base_url = str(config.get("api_base_url") or "").strip()
    if require_base_url and not base_url:
        raise ValueError("Config 'api_base_url' wajib diisi di config.json")
    if not base_url:
        return True
    if not base_url.startswith(("http://", "https://")):
        raise ValueError("api_base_url harus diawali http:// atau https://")
    return True


def _sanitize_endpoint_config(config: dict):
    cfg = dict(config or {})
    cfg["api_base_url"] = normalize_base_url(str(cfg.get("api_base_url") or ""))

    cfg["request_timeout"] = max(1, _parse_int(cfg.get("request_timeout", 3), default=3))
    cfg["http_max_retry"] = max(1, _parse_int(cfg.get("http_max_retry", 3), default=3))
    cfg["http_backoff_sec"] = max(0.0, _parse_float(cfg.get("http_backoff_sec", 1), default=1.0))
    cfg["http_backoff_factor"] = max(1.0, _parse_float(cfg.get("http_backoff_factor", 2.0), default=2.0))
    cfg["http_backoff_max_sec"] = max(0.1, _parse_float(cfg.get("http_backoff_max_sec", 30), default=30.0))
    cfg["http_backoff_jitter_ratio"] = max(
        0.0,
        min(1.0, _parse_float(cfg.get("http_backoff_jitter_ratio", 0.2), default=0.2)),
    )

    cfg["http_retry_statuses"] = _normalize_int_list(
        cfg.get("http_retry_statuses"),
        default_values=[408, 429, 500, 502, 503, 504],
        min_value=100,
        max_value=599,
    )

    for key in list(cfg.keys()):
        if not str(key).startswith("ep_"):
            continue
        if key in ("ep_receipt_qr", "ep_ip_geolocate"):
            cfg[key] = str(cfg.get(key) or "").strip()
            continue
        if key == "ep_upload_compile_status":
            endpoint_path = str(cfg.get(key) or "").strip()
            if not endpoint_path:
                endpoint_path = str(_ENDPOINT_CONFIG_DEFAULTS.get("ep_upload_compile_status") or "").strip()
            if endpoint_path and not endpoint_path.startswith("/"):
                endpoint_path = "/" + endpoint_path
            cfg[key] = endpoint_path
            continue
        endpoint_path = str(cfg.get(key) or "").strip()
        if endpoint_path and not endpoint_path.startswith("/"):
            endpoint_path = "/" + endpoint_path
        cfg[key] = endpoint_path

    return cfg


def _normalize_int_list(value, default_values=None, min_value=None, max_value=None):
    default_values = list(default_values or [])
    if isinstance(value, str):
        raw_items = [v.strip() for v in value.split(",")]
    elif isinstance(value, (list, tuple, set)):
        raw_items = list(value)
    else:
        raw_items = []

    normalized = []
    seen = set()
    for raw in raw_items:
        try:
            val = int(raw)
        except _NUMERIC_PARSE_EXCEPTIONS:
            continue
        if min_value is not None and val < int(min_value):
            continue
        if max_value is not None and val > int(max_value):
            continue
        if val in seen:
            continue
        seen.add(val)
        normalized.append(val)

    if normalized:
        return normalized

    fallback = []
    seen_fallback = set()
    for raw in default_values:
        try:
            val = int(raw)
        except _NUMERIC_PARSE_EXCEPTIONS:
            continue
        if min_value is not None and val < int(min_value):
            continue
        if max_value is not None and val > int(max_value):
            continue
        if val in seen_fallback:
            continue
        seen_fallback.add(val)
        fallback.append(val)
    return fallback


def _migrate_legacy_config_if_needed():
    if not os.path.exists(CONFIG_FILE):
        copied = _copy_first_existing_file(CONFIG_FILE, _get_config_seed_candidates())
        if not copied:
            _write_json_file(CONFIG_FILE, dict(_ENDPOINT_CONFIG_DEFAULTS))

    if not os.path.exists(APP_SETTINGS_FILE):
        copied = _copy_first_existing_file(APP_SETTINGS_FILE, _get_app_settings_seed_candidates())
        if not copied:
            _write_json_file(APP_SETTINGS_FILE, dict(_APP_SETTINGS_DEFAULTS))

    raw_cfg = _read_json_file(CONFIG_FILE)
    endpoint_cfg, app_cfg = _split_config_keys(raw_cfg)

    # edited by glg
    # Auto-heal untuk kasus first run build PyInstaller:
    # config AppData sudah terlanjur dibuat default (api_base_url kosong),
    # padahal seed config valid tersedia di bundle/_internal.
    if not str(endpoint_cfg.get("api_base_url") or "").strip():
        seed_endpoint_cfg = _load_seed_endpoint_with_base_url()
        if seed_endpoint_cfg:
            repaired = dict(raw_cfg)
            repaired.update(seed_endpoint_cfg)
            repaired = _sanitize_endpoint_config(repaired)
            _write_json_file(CONFIG_FILE, repaired)
            raw_cfg = _read_json_file(CONFIG_FILE)
            endpoint_cfg, app_cfg = _split_config_keys(raw_cfg)

    has_non_endpoint_in_config = len(app_cfg) > 0
    app_settings_missing = not os.path.exists(APP_SETTINGS_FILE)

    if not has_non_endpoint_in_config and not app_settings_missing:
        try:
            _apply_managed_app_settings_policy_if_needed()
        except (OSError, ValueError, TypeError, RuntimeError) as e:
            LOGGER.warning("[CONFIG_POLICY] Gagal apply managed policy: %s", e)
        return

    current_app_settings = _read_json_file(APP_SETTINGS_FILE) if os.path.exists(APP_SETTINGS_FILE) else {}

    merged_app = dict(_APP_SETTINGS_DEFAULTS)
    merged_app.update(current_app_settings)
    merged_app.update(app_cfg)

    merged_endpoint = dict(_ENDPOINT_CONFIG_DEFAULTS)
    merged_endpoint.update(endpoint_cfg)
    merged_endpoint = _sanitize_endpoint_config(merged_endpoint)
    _validate_endpoint_config(merged_endpoint, require_base_url=False)

    _write_json_file(APP_SETTINGS_FILE, merged_app)
    _write_json_file(CONFIG_FILE, merged_endpoint)
    try:
        _apply_managed_app_settings_policy_if_needed()
    except (OSError, ValueError, TypeError, RuntimeError) as e:
        LOGGER.warning("[CONFIG_POLICY] Gagal apply managed policy: %s", e)


def read_endpoint_config():
    global _ENDPOINT_CACHE
    with _CONFIG_CACHE_LOCK:
        if _ENDPOINT_CACHE is not None:
            return dict(_ENDPOINT_CACHE)

        _migrate_legacy_config_if_needed()
        raw_cfg = _read_json_file(CONFIG_FILE)
        endpoint_cfg, _ = _split_config_keys(raw_cfg)

        merged_endpoint = dict(_ENDPOINT_CONFIG_DEFAULTS)
        merged_endpoint.update(endpoint_cfg)
        merged_endpoint = _sanitize_endpoint_config(merged_endpoint)
        _validate_endpoint_config(merged_endpoint, require_base_url=False)

        if not str(endpoint_cfg.get("ep_upload_compile_status") or "").strip():
            persisted_cfg = dict(raw_cfg)
            persisted_cfg["ep_upload_compile_status"] = merged_endpoint.get("ep_upload_compile_status") or ""
            _write_json_file(CONFIG_FILE, _sanitize_endpoint_config(persisted_cfg))

        _ENDPOINT_CACHE = merged_endpoint
        return dict(_ENDPOINT_CACHE)


def read_app_settings():
    global _APP_SETTINGS_CACHE
    with _CONFIG_CACHE_LOCK:
        if _APP_SETTINGS_CACHE is not None:
            return dict(_APP_SETTINGS_CACHE)

        _migrate_legacy_config_if_needed()
        raw_app = _read_json_file(APP_SETTINGS_FILE)
        _, app_settings = _split_config_keys(raw_app)

        merged_settings = dict(_APP_SETTINGS_DEFAULTS)
        merged_settings.update(app_settings)

        _APP_SETTINGS_CACHE = merged_settings
        return dict(_APP_SETTINGS_CACHE)


def read_config():
    global _MERGED_CACHE
    with _CONFIG_CACHE_LOCK:
        if _MERGED_CACHE is not None:
            return dict(_MERGED_CACHE)

        merged = read_app_settings()
        merged.update(read_endpoint_config())
        _MERGED_CACHE = merged
        return dict(_MERGED_CACHE)


def reload_config():
    global _ENDPOINT_CACHE, _APP_SETTINGS_CACHE, _MERGED_CACHE
    with _CONFIG_CACHE_LOCK:
        _ENDPOINT_CACHE = None
        _APP_SETTINGS_CACHE = None
        _MERGED_CACHE = None
    return read_config()


def save_endpoint_config(config):
    with _CONFIG_CACHE_LOCK:
        current = read_endpoint_config()
        updates, _ = _split_config_keys(config or {})
        if not updates:
            return
        current.update(updates)
        current = _sanitize_endpoint_config(current)
        _validate_endpoint_config(current, require_base_url=True)
        _write_json_file(CONFIG_FILE, current)
        reload_config()


def save_app_settings(settings):
    with _CONFIG_CACHE_LOCK:
        current = read_app_settings()
        _, updates = _split_config_keys(settings or {})
        current.update(updates)
        _write_json_file(APP_SETTINGS_FILE, current)
        reload_config()


def save_config(config):
    with _CONFIG_CACHE_LOCK:
        if not isinstance(config, dict):
            raise ValueError("save_config membutuhkan payload dict.")
        endpoint_cfg, app_cfg = _split_config_keys(config)
        if endpoint_cfg:
            save_endpoint_config(endpoint_cfg)
        if app_cfg:
            save_app_settings(app_cfg)
        if not endpoint_cfg and not app_cfg:
            reload_config()


def normalize_base_url(raw_url: str) -> str:
    url = (raw_url or "").strip()
    if not url:
        return ""
    if "://" not in url:
        url = "https://" + url
    parsed = urlparse(url)
    scheme = (parsed.scheme or "https").lower()
    netloc = (parsed.netloc or "").lower()
    path = (parsed.path or "").rstrip("/")

    if scheme == "http" and netloc.endswith(":80"):
        netloc = netloc[:-3]
    elif scheme == "https" and netloc.endswith(":443"):
        netloc = netloc[:-4]

    normalized = urlunparse((scheme, netloc, path, "", "", ""))
    return normalized.rstrip("/")


def compute_server_hash(raw_url: str) -> str:
    normalized = normalize_base_url(raw_url)
    if not normalized:
        return ""
    return hashlib.sha256(normalized.encode("utf-8")).hexdigest()


def get_current_server_hash() -> str:
    return compute_server_hash(read_endpoint_config().get("api_base_url"))


def get_http_retry_settings(config=None):
    policy = get_http_retry_policy(config)
    return int(policy["max_retry"]), float(policy["backoff_sec"])


def get_http_retry_policy(config=None):
    cfg = config if isinstance(config, dict) else read_endpoint_config()
    max_retry = _parse_int(cfg.get("http_max_retry", 3), default=3)
    if max_retry < 1:
        max_retry = 1

    backoff_sec = _parse_float(cfg.get("http_backoff_sec", 1), default=1.0)
    if backoff_sec < 0:
        backoff_sec = 0.0

    backoff_factor = _parse_float(cfg.get("http_backoff_factor", 2.0), default=2.0)
    if backoff_factor < 1.0:
        backoff_factor = 1.0

    backoff_max_sec = _parse_float(cfg.get("http_backoff_max_sec", 30.0), default=30.0)
    if backoff_max_sec <= 0:
        backoff_max_sec = max(1.0, backoff_sec)

    jitter_ratio = _parse_float(cfg.get("http_backoff_jitter_ratio", 0.2), default=0.2)
    jitter_ratio = max(0.0, min(1.0, jitter_ratio))

    retry_statuses = _normalize_int_list(
        cfg.get("http_retry_statuses"),
        default_values=[408, 429, 500, 502, 503, 504],
        min_value=100,
        max_value=599,
    )
    retry_statuses_set = set(retry_statuses)

    return {
        "max_retry": max_retry,
        "backoff_sec": backoff_sec,
        "backoff_factor": backoff_factor,
        "backoff_max_sec": backoff_max_sec,
        "jitter_ratio": jitter_ratio,
        "retry_statuses": retry_statuses,
        "retry_statuses_set": retry_statuses_set,
    }


def is_sql_identifier_strict_mode_enabled(config=None, default=1):
    cfg = config if isinstance(config, dict) else read_config()
    raw = cfg.get("sql_identifier_strict_mode", default)
    if isinstance(raw, bool):
        return raw
    text = str(raw or "").strip().lower()
    if text in {"1", "true", "yes", "on"}:
        return True
    if text in {"0", "false", "no", "off"}:
        return False
    return bool(default)


def get_sync_watch_tables(config=None):
    cfg = config if isinstance(config, dict) else read_config()
    raw_tables = cfg.get("sync_watch_tables")
    if raw_tables is None:
        raw_tables = cfg.get("sync_watch_list")
    if raw_tables is None:
        raw_tables = _APP_SETTINGS_DEFAULTS.get("sync_watch_tables", [])

    if isinstance(raw_tables, str):
        raw_tables = [v.strip() for v in raw_tables.split(",")]
    elif not isinstance(raw_tables, list):
        raw_tables = []

    normalized = []
    seen = set()
    for val in raw_tables:
        table_name = str(val or "").strip()
        if not table_name:
            continue
        if table_name in seen:
            continue
        seen.add(table_name)
        normalized.append(table_name)

    if not normalized:
        fallback = _APP_SETTINGS_DEFAULTS.get("sync_watch_tables", [])
        normalized = [str(v).strip() for v in fallback if str(v).strip()]
    return normalized


def get_jwt_runtime_config(config=None):
    global _JWT_OPTIONAL_WARNED
    cfg = config if isinstance(config, dict) else read_config()

    def _to_bool(value, default=0):
        if isinstance(value, bool):
            return value
        if value is None:
            return bool(default)
        text = str(value).strip().lower()
        if text in {"1", "true", "yes", "on"}:
            return True
        if text in {"0", "false", "no", "off"}:
            return False
        return bool(default)

    def _to_int(value, default=0, minimum=0):
        parsed = _parse_int(value, default=default)
        if parsed < int(minimum):
            return int(minimum)
        return parsed

    runtime_cfg = {
        "enabled": _to_bool(cfg.get("jwt_enabled"), default=1),
        "required": _to_bool(cfg.get("jwt_required"), default=0),
        "auto_refresh": _to_bool(cfg.get("jwt_auto_refresh"), default=1),
        "refresh_skew_seconds": _to_int(cfg.get("jwt_refresh_skew_seconds"), default=60, minimum=0),
        "attach_internal_only": _to_bool(cfg.get("jwt_attach_internal_only"), default=1),
    }
    # edited by glg
    # Guard non-breaking: tetap kompatibel, tetapi berikan sinyal keras saat JWT opsional.
    if (
        runtime_cfg.get("enabled")
        and not runtime_cfg.get("required")
        and not _JWT_OPTIONAL_WARNED
    ):
        LOGGER.warning(
            "[JWT_RUNTIME] jwt_enabled=1 tetapi jwt_required=0 "
            "(mode kompatibilitas). Disarankan set jwt_required=1 saat endpoint JWT siap penuh."
        )
        _JWT_OPTIONAL_WARNED = True
    return runtime_cfg

