# -*- coding: utf-8 -*-
"""
LVT Sync - Main dialog with Login, Projects, and Sync tabs.
Bilingual (English / Vietnamese) — follows LVT4U language setting.
Author: Lộc Vũ Trung
"""

import json
import hashlib
import traceback
from datetime import datetime
import os
import zipfile
import tempfile

from qgis.PyQt.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QFormLayout,
    QTabWidget, QWidget, QLabel, QLineEdit,
    QPushButton, QTableWidget, QTableWidgetItem, QTextEdit,
    QProgressBar, QHeaderView, QMessageBox, QAbstractItemView,
    QGroupBox, QTreeWidget, QTreeWidgetItem, QInputDialog
)
from qgis.PyQt.QtCore import QThread, pyqtSignal
from ..shared.qt_compat import QVariant, Qt
from qgis.PyQt.QtGui import QFont

from qgis.core import (
    QgsWkbTypes,
    QgsProject, QgsVectorLayer, QgsFeature, QgsGeometry,
    QgsPointXY, QgsField, QgsVectorFileWriter,
    QgsCoordinateTransformContext, QgsCoordinateReferenceSystem,
    QgsCoordinateTransform
)

from .gdrive_client import GoogleDriveClient
from ..shared.file_utils import gpkg_wal_checkpoint, is_onedrive_cloud_file
from ..shared.qt_compat import QtCompat, MsgButton
from ..shared.i18n import tr, language_signals


# ---------------------------------------------------------------------------
# Google OAuth worker (runs in background thread to avoid blocking QGIS)
# ---------------------------------------------------------------------------

class _GoogleLoginWorker(QThread):
    """Run GoogleDriveClient.login() in a background thread."""
    success = pyqtSignal(str, str)   # (email, name)
    error   = pyqtSignal(str)        # error message

    def __init__(self, client):
        super().__init__()
        self._client = client

    def run(self):
        try:
            self._client.login()
            self.success.emit(
                self._client.user_email or '',
                self._client.user_name or ''
            )
        except Exception as exc:  # noqa: BLE001
            self.error.emit(str(exc))


# ---------------------------------------------------------------------------
# Geometry helpers
# ---------------------------------------------------------------------------

def _qgis_geom_type_to_server(wkb_type):
    """Map QGIS WKB geometry type to server string."""
    flat = QgsWkbTypes.flatType(wkb_type)
    mapping = {
        QgsWkbTypes.Type.Point: 'point',
        QgsWkbTypes.Type.MultiPoint: 'point',
        QgsWkbTypes.Type.LineString: 'linestring',
        QgsWkbTypes.Type.MultiLineString: 'linestring',
        QgsWkbTypes.Type.Polygon: 'polygon',
        QgsWkbTypes.Type.MultiPolygon: 'polygon',
    }
    return mapping.get(flat, 'point')


def _server_geom_type_to_qgis_uri(geom_type):
    """Map server geometry type string to QGIS memory layer URI type."""
    mapping = {
        'point': 'Point',
        'linestring': 'LineString',
        'polygon': 'Polygon',
    }
    return mapping.get(geom_type, 'Point')


def _coords_json_to_qgs_geometry(coords_json_str, geom_type):
    """Convert server coordinates_json to QgsGeometry.

    Server format: [[lng, lat], [lng, lat], ...] (GeoJSON standard)
    QGIS uses QgsPointXY(x=lng, y=lat).
    """
    try:
        coords = json.loads(coords_json_str) if isinstance(coords_json_str, str) else coords_json_str
    except (json.JSONDecodeError, TypeError):
        return None

    if not coords:
        return None

    if geom_type == 'point':
        # Single point: [lng, lat] or [[lng, lat]]
        if isinstance(coords[0], (int, float)):
            lng, lat = coords[0], coords[1]
        else:
            lng, lat = coords[0][0], coords[0][1]
        return QgsGeometry.fromPointXY(QgsPointXY(lng, lat))

    elif geom_type == 'linestring':
        points = [QgsPointXY(c[0], c[1]) for c in coords]
        return QgsGeometry.fromPolylineXY(points)

    elif geom_type == 'polygon':
        points = [QgsPointXY(c[0], c[1]) for c in coords]
        # Close ring if needed
        if points and points[0] != points[-1]:
            points.append(points[0])
        return QgsGeometry.fromPolygonXY([points])

    return None


def _qgs_geometry_to_coords_json(geometry, geom_type):
    """Convert QgsGeometry to server coordinates_json string.

    Returns JSON string of [[lng, lat], ...] (GeoJSON standard).
    """
    if geometry is None or geometry.isEmpty():
        return '[]'

    if geom_type == 'point':
        pt = geometry.asPoint()
        return json.dumps([[pt.x(), pt.y()]])

    elif geom_type == 'linestring':
        # Handle both single and multi linestring
        if geometry.isMultipart():
            lines = geometry.asMultiPolyline()
            points = lines[0] if lines else []
        else:
            points = geometry.asPolyline()
        return json.dumps([[p.x(), p.y()] for p in points])

    elif geom_type == 'polygon':
        # Handle both single and multi polygon
        if geometry.isMultipart():
            polygons = geometry.asMultiPolygon()
            ring = polygons[0][0] if polygons and polygons[0] else []
        else:
            rings = geometry.asPolygon()
            ring = rings[0] if rings else []
        return json.dumps([[p.x(), p.y()] for p in ring])

    return '[]'


# ---------------------------------------------------------------------------
# Main Dialog
# ---------------------------------------------------------------------------

def _generate_pb_id(base_str):
    """Generate a stable 15-char PocketBase ID from any string."""
    h = hashlib.md5(base_str.encode('utf-8'), usedforsecurity=False).hexdigest()
    return h[:15]


class LVTSyncDialog(QDialog):
    """Main dialog for LVT Sync plugin with two tabs (Google + Export).

    Bilingual: all visible strings are wrapped in tr() and refreshed
    via _retranslate_ui() when the user switches language.
    """

    def __init__(self, iface, parent=None):
        super().__init__(parent)
        self.iface = iface
        self.client = GoogleDriveClient()
        self._projects_cache = []

        self._setup_ui()
        self._connect_signals()
        self._update_auth_ui()
        # Connect live language switching
        language_signals.language_changed.connect(self._retranslate_ui)

    # ------------------------------------------------------------------
    # UI Setup
    # ------------------------------------------------------------------

    def _setup_ui(self):
        """Build the complete dialog UI."""
        self.setWindowTitle(tr('LVT Sync — Google Drive & Export'))
        self.resize(700, 600)
        self.setMinimumSize(600, 500)

        layout = QVBoxLayout(self)
        layout.setContentsMargins(8, 8, 8, 8)

        # Tab widget
        self.tabs = QTabWidget()
        layout.addWidget(self.tabs)

        self.tabs.addTab(self._create_google_tab(), tr('☁️ Google'))
        self.tabs.addTab(self._create_export_tab(), tr('📤 Export'))

        # Bottom status bar
        self.lbl_status_bar = QLabel('')
        self.lbl_status_bar.setStyleSheet('color: #666; font-size: 11px; padding: 2px;')
        layout.addWidget(self.lbl_status_bar)

    # -- Tab 1: Google (Login + Projects + Sync) ----------------------------

    def _create_google_tab(self):
        """Create the combined Google tab with login, projects, and sync."""
        from qgis.PyQt.QtWidgets import QScrollArea
        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setSpacing(10)

        # ---- Login section ----
        grp_login = QGroupBox(tr('Google Account'))
        login_layout = QVBoxLayout(grp_login)

        info_label = QLabel(
            tr('Data is stored on your personal Google Drive.\n'
               'Folder: Google Drive / LVTField / projects\n'
               'Free — 15 GB per Google account.')
        )
        info_label.setWordWrap(True)
        info_label.setStyleSheet('color: #555; font-size: 11px;')
        login_layout.addWidget(info_label)

        btn_row = QHBoxLayout()
        self.btn_login = QPushButton(tr('🔑 Sign in with Google'))
        self.btn_login.setStyleSheet(
            'QPushButton { background-color: #4285F4; color: white; padding: 10px 24px; '
            'border-radius: 6px; font-weight: bold; font-size: 13px; } '
            'QPushButton:hover { background-color: #3367D6; }'
        )
        self.btn_login.clicked.connect(self._on_login)
        self.btn_logout = QPushButton(tr('Sign out'))
        self.btn_logout.setEnabled(False)
        self.btn_logout.clicked.connect(self._on_logout)
        btn_row.addWidget(self.btn_login)
        btn_row.addWidget(self.btn_logout)
        btn_row.addStretch()
        login_layout.addLayout(btn_row)

        self.lbl_auth_status = QLabel(tr('⚪ Not signed in'))
        self.lbl_auth_status.setFont(QFont('Segoe UI', 10))
        login_layout.addWidget(self.lbl_auth_status)
        self.lbl_user_info = QLabel('')
        login_layout.addWidget(self.lbl_user_info)

        layout.addWidget(grp_login)

        # ---- Projects table section ----
        grp_projects = QGroupBox(tr('📁 Projects on Google Drive'))
        projects_layout = QVBoxLayout(grp_projects)

        toolbar = QHBoxLayout()
        self.btn_refresh_projects = QPushButton(tr('🔄 Refresh'))
        self.btn_refresh_projects.clicked.connect(self._on_refresh_projects)
        self.btn_delete_gdrive_project = QPushButton(tr('🗑️ Delete project'))
        self.btn_delete_gdrive_project.setStyleSheet(
            'QPushButton { background-color: #f44336; color: white; padding: 6px 16px; '
            'border-radius: 4px; font-weight: bold; } '
            'QPushButton:hover { background-color: #d32f2f; }'
        )
        self.btn_delete_gdrive_project.clicked.connect(self._on_delete_gdrive_project)
        toolbar.addWidget(self.btn_refresh_projects)
        toolbar.addStretch()
        toolbar.addWidget(self.btn_delete_gdrive_project)
        projects_layout.addLayout(toolbar)

        self.tbl_projects = QTableWidget(0, 4)
        self.tbl_projects.setHorizontalHeaderLabels([
            tr('Project name'), tr('Description'), tr('Layers'), tr('ID')
        ])
        header = self.tbl_projects.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
        header.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
        self.tbl_projects.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.tbl_projects.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
        self.tbl_projects.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.tbl_projects.setAlternatingRowColors(True)
        self.tbl_projects.setMaximumHeight(180)
        projects_layout.addWidget(self.tbl_projects)

        self.lbl_projects_info = QLabel(tr('Press "Refresh" to load projects.'))
        self.lbl_projects_info.setStyleSheet('color: #888;')
        projects_layout.addWidget(self.lbl_projects_info)

        layout.addWidget(grp_projects)

        # ---- Sync section ----
        grp_sync = QGroupBox(tr('🔄 Sync'))
        sync_layout = QVBoxLayout(grp_sync)

        self.btn_sync_gdrive = QPushButton(tr('🔄 Sync Google Drive'))
        self.btn_sync_gdrive.setStyleSheet(
            'QPushButton { background-color: #1976D2; color: white; padding: 10px 24px; '
            'border-radius: 6px; font-weight: bold; font-size: 13px; } '
            'QPushButton:hover { background-color: #1565C0; }'
        )
        self.btn_sync_gdrive.clicked.connect(self._on_sync_gdrive)
        sync_layout.addWidget(self.btn_sync_gdrive)

        self.progress = QProgressBar()
        self.progress.setRange(0, 100)
        self.progress.setValue(0)
        self.progress.setTextVisible(True)
        sync_layout.addWidget(self.progress)

        info_row = QHBoxLayout()
        self.lbl_last_sync = QLabel(tr('Last sync: —'))
        self.lbl_sync_stats = QLabel('')
        info_row.addWidget(self.lbl_last_sync)
        info_row.addStretch()
        info_row.addWidget(self.lbl_sync_stats)
        sync_layout.addLayout(info_row)

        layout.addWidget(grp_sync)

        # ---- Log area ----
        lbl_log = QLabel(tr('Log:'))
        lbl_log.setFont(QFont('Segoe UI', 9, QFont.Weight.Bold))
        layout.addWidget(lbl_log)

        self.txt_log = QTextEdit()
        self.txt_log.setReadOnly(True)
        self.txt_log.setFont(QFont('Consolas', 9))
        self.txt_log.setStyleSheet('background-color: #1e1e1e; color: #d4d4d4; padding: 4px;')
        layout.addWidget(self.txt_log)

        scroll.setWidget(widget)
        return scroll

    # -- Tab 4: Export -------------------------------------------------------

    def _create_export_tab(self):
        """Create the file export tab."""
        widget = QWidget()
        layout = QVBoxLayout(widget)
        layout.setSpacing(10)

        # Output directory
        grp_output = QGroupBox(tr('Export folder'))
        out_layout = QHBoxLayout(grp_output)
        self.txt_export_dir = QLineEdit()
        self.txt_export_dir.setPlaceholderText(tr('Choose export folder...'))
        # Default to user's Desktop
        default_dir = os.path.join(os.path.expanduser('~'), 'Desktop')
        if os.path.exists(default_dir):
            self.txt_export_dir.setText(default_dir)
        self.btn_browse_export = QPushButton(tr('Browse...'))
        self.btn_browse_export.setFixedWidth(80)
        out_layout.addWidget(self.txt_export_dir)
        out_layout.addWidget(self.btn_browse_export)
        layout.addWidget(grp_output)

        # Layer selection group
        grp_layers = QGroupBox(tr('Select layers to export'))
        layers_layout = QVBoxLayout(grp_layers)

        # Quick action buttons
        btn_row = QHBoxLayout()
        self.btn_select_all = QPushButton(tr('Select All'))
        self.btn_select_all.setFixedHeight(28)
        self.btn_select_all.setStyleSheet(
            'QPushButton { background-color: #4CAF50; color: white; padding: 4px 12px; '
            'border-radius: 3px; font-size: 11px; } '
            'QPushButton:hover { background-color: #388E3C; }'
        )
        self.btn_deselect_all = QPushButton(tr('Deselect All'))
        self.btn_deselect_all.setFixedHeight(28)
        self.btn_deselect_all.setStyleSheet(
            'QPushButton { background-color: #FF9800; color: white; padding: 4px 12px; '
            'border-radius: 3px; font-size: 11px; } '
            'QPushButton:hover { background-color: #F57C00; }'
        )
        self.btn_visible_only = QPushButton(tr('Visible only'))
        self.btn_visible_only.setFixedHeight(28)
        self.btn_visible_only.setStyleSheet(
            'QPushButton { background-color: #2196F3; color: white; padding: 4px 12px; '
            'border-radius: 3px; font-size: 11px; } '
            'QPushButton:hover { background-color: #1976D2; }'
        )
        self.btn_refresh_layers = QPushButton('🔄')
        self.btn_refresh_layers.setFixedSize(28, 28)
        self.btn_refresh_layers.setToolTip(tr('Refresh layer list'))
        btn_row.addWidget(self.btn_select_all)
        btn_row.addWidget(self.btn_deselect_all)
        btn_row.addWidget(self.btn_visible_only)
        btn_row.addStretch()
        btn_row.addWidget(self.btn_refresh_layers)
        layers_layout.addLayout(btn_row)

        # Layer tree widget
        self.tree_layers = QTreeWidget()
        self.tree_layers.setHeaderLabels([tr('Layer'), tr('Type'), tr('Features')])
        self.tree_layers.setRootIsDecorated(False)
        self.tree_layers.setAlternatingRowColors(True)
        self.tree_layers.setMaximumHeight(180)
        tree_header = self.tree_layers.header()
        tree_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
        tree_header.setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents)
        tree_header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
        layers_layout.addWidget(self.tree_layers)

        # Selection summary
        self.lbl_layer_summary = QLabel('')
        self.lbl_layer_summary.setStyleSheet('color: #888; font-size: 11px; padding: 2px;')
        layers_layout.addWidget(self.lbl_layer_summary)

        layout.addWidget(grp_layers)

        # GPKG export group
        grp_gpkg = QGroupBox(tr('Export GPKG — single file with data + styles'))
        gpkg_layout = QVBoxLayout(grp_gpkg)
        self.chk_save_styles = None  # Will use checkbox
        from qgis.PyQt.QtWidgets import QCheckBox
        self.chk_save_styles = QCheckBox(tr('Save styles (labels, colours, line styles)'))
        self.chk_save_styles.setChecked(True)
        gpkg_layout.addWidget(self.chk_save_styles)
        self.btn_export_gpkg = QPushButton(tr('📦 Export GPKG'))
        self.btn_export_gpkg.setStyleSheet(
            'QPushButton { background-color: #4CAF50; color: white; padding: 8px 20px; '
            'border-radius: 4px; font-weight: bold; font-size: 12px; } '
            'QPushButton:hover { background-color: #388E3C; }'
        )
        gpkg_layout.addWidget(self.btn_export_gpkg)
        layout.addWidget(grp_gpkg)

        # ZIP export group
        grp_zip = QGroupBox(tr('Export ZIP project — .qgs + all .gpkg files'))
        zip_layout = QVBoxLayout(grp_zip)
        zip_info = QLabel(
            tr('Bundle the entire QGIS project into one ZIP file.\n'
               'Includes: project.qgs + layer .gpkg files + styles.\n'
               'Use to import into LVTField on mobile.')
        )
        zip_info.setStyleSheet('color: #666; font-size: 11px;')
        zip_info.setWordWrap(True)
        zip_layout.addWidget(zip_info)
        self.btn_export_zip = QPushButton(tr('📦 Export ZIP project'))
        self.btn_export_zip.setStyleSheet(
            'QPushButton { background-color: #2196F3; color: white; padding: 8px 20px; '
            'border-radius: 4px; font-weight: bold; font-size: 12px; } '
            'QPushButton:hover { background-color: #1976D2; }'
        )
        zip_layout.addWidget(self.btn_export_zip)
        layout.addWidget(grp_zip)

        # Export progress
        self.export_progress = QProgressBar()
        self.export_progress.setRange(0, 100)
        self.export_progress.setValue(0)
        self.export_progress.setTextVisible(True)
        layout.addWidget(self.export_progress)

        # Export log
        lbl_export_log = QLabel(tr('Export log:'))
        lbl_export_log.setFont(QFont('Segoe UI', 9, QFont.Weight.Bold))
        layout.addWidget(lbl_export_log)

        self.txt_export_log = QTextEdit()
        self.txt_export_log.setReadOnly(True)
        self.txt_export_log.setFont(QFont('Consolas', 9))
        self.txt_export_log.setStyleSheet(
            'background-color: #1e1e1e; color: #d4d4d4; padding: 4px;'
        )
        self.txt_export_log.setMaximumHeight(150)
        layout.addWidget(self.txt_export_log)

        layout.addStretch()
        return widget

    # ------------------------------------------------------------------
    # Signal connections
    # ------------------------------------------------------------------

    def _connect_signals(self):
        """Connect export tab signals and tab change handler.

        Google tab and VPS tab signals are connected directly in their
        respective _create_*_tab() methods.
        """
        self.btn_browse_export.clicked.connect(self._on_browse_export_dir)
        self.btn_export_gpkg.clicked.connect(self._on_export_gpkg)
        self.btn_export_zip.clicked.connect(self._on_export_zip)
        self.btn_select_all.clicked.connect(self._on_select_all_layers)
        self.btn_deselect_all.clicked.connect(self._on_deselect_all_layers)
        self.btn_visible_only.clicked.connect(self._on_visible_only_layers)
        self.btn_refresh_layers.clicked.connect(self._refresh_layer_list)
        self.tabs.currentChanged.connect(self._on_tab_changed)
        self.tree_layers.itemChanged.connect(lambda _: self._update_layer_summary())

    def _log(self, message, level='info'):
        """Append a timestamped message to the sync log.

        Args:
            message: Log message text.
            level: One of 'info', 'ok', 'warn', 'error'.
        """
        timestamp = datetime.now().strftime('%H:%M:%S')
        colors = {
            'info': '#d4d4d4',
            'ok': '#4EC9B0',
            'warn': '#DCDCAA',
            'error': '#F44747',
        }
        color = colors.get(level, colors['info'])
        self.txt_log.append(
            f'<span style="color:#888">[{timestamp}]</span> '
            f'<span style="color:{color}">{message}</span>'
        )

    # ------------------------------------------------------------------
    # Auth UI
    # ------------------------------------------------------------------

    def _update_auth_ui(self):
        """Update login UI elements based on authentication state."""
        if self.client.is_authenticated():
            self.lbl_auth_status.setText(tr('🟢 Signed in'))
            self.lbl_auth_status.setStyleSheet('color: #2e7d32; font-weight: bold;')
            self.lbl_user_info.setText(
                f'👤 {self.client.user_name or "Google User"}\n'
                f'📧 {self.client.user_email or ""}'
            )
            self.btn_login.setEnabled(False)
            self.btn_logout.setEnabled(True)
            self.lbl_status_bar.setText(
                tr('Signed in: {email}').format(email=self.client.user_email)
            )
            # Auto-load projects
            try:
                self._on_refresh_projects()
            except Exception:  # nosec B110
                pass  # nosec B110
        else:
            self.lbl_auth_status.setText(tr('⚪ Not signed in'))
            self.lbl_auth_status.setStyleSheet('color: #666;')
            self.lbl_user_info.setText('')
            self.btn_login.setEnabled(True)
            self.btn_logout.setEnabled(False)
            self.lbl_status_bar.setText('')

    def _on_login(self):
        """Handle Google Sign-In button click (non-blocking via QThread)."""
        self.btn_login.setEnabled(False)
        self.btn_login.setText(tr('⏳ Waiting for Google confirmation...'))

        self._login_worker = _GoogleLoginWorker(self.client)
        self._login_worker.success.connect(self._on_login_success)
        self._login_worker.error.connect(self._on_login_error)
        self._login_worker.finished.connect(self._on_login_finished)
        self._login_worker.start()

    def _on_login_success(self, email, name):
        """Called from worker thread when Google login succeeds."""
        self._update_auth_ui()
        self._on_refresh_projects()
        self._log(tr('Google login successful: {email}').format(email=email), 'ok')
        QMessageBox.information(
            self, tr('Success'),
            tr('Signed in to Google!\nHello {name}').format(name=name or email)
        )

    def _on_login_error(self, msg):
        """Called from worker thread when Google login fails."""
        QMessageBox.critical(self, tr('Login failed'), tr('Error:\n{msg}').format(msg=msg))
        self._log(f'Google login failed: {msg}', 'error')

    def _on_login_finished(self):
        """Re-enable login button after thread finishes (success or error)."""
        self.btn_login.setEnabled(True)
        self.btn_login.setText(tr('🔑 Sign in with Google'))

    def _on_logout(self):
        """Handle logout button click."""
        self.client.logout()
        self._update_auth_ui()
        self._log('Đã đăng xuất.', 'info')

    # -- Projects -----------------------------------------------------------

    def _on_refresh_projects(self):
        """Fetch and display projects from Google Drive."""
        if not self.client.is_authenticated():
            QMessageBox.warning(self, tr('Not signed in'), tr('Please sign in first.'))
            self.tabs.setCurrentIndex(0)
            return

        self.btn_refresh_projects.setEnabled(False)
        self.lbl_projects_info.setText(tr('Loading...'))
        from qgis.PyQt.QtWidgets import QApplication
        QApplication.processEvents()

        try:
            projects = self.client.list_projects()
            self._projects_cache = projects
            self._populate_projects_table(projects)
            self.lbl_projects_info.setText(tr('Found {n} projects.').format(n=len(projects)))
        except Exception as exc:
            self.lbl_projects_info.setText(f'Error: {exc}')
        finally:
            self.btn_refresh_projects.setEnabled(True)

    def _populate_projects_table(self, projects):
        """Fill the projects table with data."""
        self.tbl_projects.setRowCount(0)
        for proj in projects:
            row = self.tbl_projects.rowCount()
            self.tbl_projects.insertRow(row)
            self.tbl_projects.setItem(row, 0, QTableWidgetItem(proj.get('name', '')))
            self.tbl_projects.setItem(row, 1, QTableWidgetItem(proj.get('modified', '')))
            self.tbl_projects.setItem(row, 2, QTableWidgetItem('—'))
            self.tbl_projects.setItem(row, 3, QTableWidgetItem(proj.get('id', '')))

    def _on_download_project(self):
        """Download selected project from Google Drive."""
        if not self.client.is_authenticated():
            QMessageBox.warning(self, tr('Not signed in'), tr('Please sign in first.'))
            return
        selected = self.tbl_projects.selectedItems()
        if not selected:
            QMessageBox.warning(self, tr('Nothing selected'), tr('Please select a project.'))
            return
        row = selected[0].row()
        project_name = self.tbl_projects.item(row, 0).text()
        project_id = self.tbl_projects.item(row, 3).text()
        reply = QMessageBox.question(
            self, tr('Confirm download'),
            tr('Download project "{name}" to QGIS?').format(name=project_name),
            MsgButton.Yes | MsgButton.No, MsgButton.No
        )
        if reply != MsgButton.Yes:
            return
        self._download_project(project_id, project_name)

    def _download_project(self, project_id, project_name):
        """Download project from Google Drive and add to QGIS.

        Supports TWO formats:
        1. JSON files (project.json + layers.json + features.json) — from LVTField app
        2. GPKG files (.gpkg/.gpkg.zip) — legacy format
        """
        from qgis.PyQt.QtWidgets import QApplication

        self._log(f'Bắt đầu tải: {project_name}', 'info')
        self.progress.setValue(0)

        try:
            files = self.client.list_files_in_project(project_id)
            file_names = [f['name'] for f in files]
            self._log(f'Tìm thấy {len(files)} files: {file_names}', 'info')
            QApplication.processEvents()

            # Check if JSON format (from LVTField mobile app)
            has_json = 'layers.json' in file_names and 'features.json' in file_names

            if has_json:
                self._download_project_json(project_id, project_name, files)
            else:
                # Legacy GPKG format
                self._download_project_gpkg(project_id, project_name, files)

        except Exception as exc:
            self._log(f'Lỗi tải dự án: {exc}', 'error')
            self._log(traceback.format_exc(), 'error')

    def _download_project_json(self, project_id, project_name, files):
        """Download JSON-format project (from LVTField mobile app) and create QGIS layers."""
        from qgis.PyQt.QtWidgets import QApplication
        from qgis.PyQt.QtGui import QColor

        try:
            # Download layers.json
            self._log('  ⬇ Tải layers.json...', 'info')
            QApplication.processEvents()
            layers_file = next((f for f in files if f['name'] == 'layers.json'), None)
            if not layers_file:
                self._log('❌ Không tìm thấy layers.json', 'error')
                return
            layers_data = json.loads(self.client._api_download(layers_file['id']).decode('utf-8'))
            layers_list = layers_data.get('layers', [])
            self._log(f'  ✅ {len(layers_list)} layers', 'ok')
            self.progress.setValue(20)
            QApplication.processEvents()

            # Download features.json
            self._log('  ⬇ Tải features.json...', 'info')
            QApplication.processEvents()
            features_file = next((f for f in files if f['name'] == 'features.json'), None)
            if not features_file:
                self._log('❌ Không tìm thấy features.json', 'error')
                return
            features_data = json.loads(self.client._api_download(features_file['id']).decode('utf-8'))
            all_features = features_data.get('features', [])
            self._log(f'  ✅ {len(all_features)} features', 'ok')
            self.progress.setValue(40)
            QApplication.processEvents()

            # Group features by layer_id
            features_by_layer = {}
            for feat in all_features:
                lid = feat.get('layer_id', '')
                if lid not in features_by_layer:
                    features_by_layer[lid] = []
                # Map JSON format to what _create_qgis_layer expects
                mapped = {
                    'id': feat.get('id', ''),
                    'layer_id': lid,
                    'coordinates_json': feat.get('geometry', '[]'),
                    'attributes': feat.get('properties', '{}'),
                    'version': feat.get('version', 1),
                }
                features_by_layer[lid].append(mapped)

            # Create QGIS layers
            created = 0
            for idx, layer_info in enumerate(layers_list):
                layer_id = layer_info.get('id', '')
                layer_name = layer_info.get('name', f'Layer_{idx}')
                geom_type = layer_info.get('geometry_type', 'polygon')
                layer_features = features_by_layer.get(layer_id, [])

                self._log(f'  🗺 Tạo layer: {layer_name} ({len(layer_features)} features)', 'info')
                QApplication.processEvents()

                qgs_layer = self._create_qgis_layer(
                    name=layer_name,
                    geom_type=geom_type,
                    features=layer_features,
                    group_name=project_name,
                )

                if qgs_layer and qgs_layer.isValid():
                    # Apply style if available
                    style = layer_info.get('style_config', {})
                    if isinstance(style, str):
                        try:
                            style = json.loads(style)
                        except (json.JSONDecodeError, TypeError):
                            style = {}
                    if style and isinstance(style, dict):
                        color_str = style.get('color', '')
                        if color_str:
                            try:
                                sym = qgs_layer.renderer().symbol()
                                sym.setColor(QColor(color_str))
                                opacity = style.get('opacity', 1.0)
                                if isinstance(opacity, (int, float)):
                                    sym.setOpacity(float(opacity))
                            except Exception:  # nosec B110
                                pass  # nosec B110

                    QgsProject.instance().addMapLayer(qgs_layer)
                    created += 1
                    self._log(f'    ✅ {layer_name}: {len(layer_features)} features', 'ok')
                else:
                    self._log(f'    ❌ Lỗi tạo layer {layer_name}', 'error')

                pct = 40 + int(((idx + 1) / max(len(layers_list), 1)) * 55)
                self.progress.setValue(pct)
                QApplication.processEvents()

            self.progress.setValue(100)
            self._log(f'✅ Hoàn tất! Đã tải {created}/{len(layers_list)} layers từ {project_name}', 'ok')
            self.lbl_last_sync.setText(f'Lần đồng bộ cuối: {datetime.now().strftime("%H:%M:%S %d/%m/%Y")}')

        except Exception as exc:
            self._log(f'Lỗi tải JSON: {exc}', 'error')
            self._log(traceback.format_exc(), 'error')

    def _download_project_gpkg(self, project_id, project_name, files):
        """Download GPKG-format project files and add to QGIS (legacy)."""
        from qgis.PyQt.QtWidgets import QApplication
        import tempfile

        dl_files = [f for f in files if f['name'].endswith('.gpkg.zip') or f['name'].endswith('.gpkg')]
        self._log(f'Tìm thấy {len(dl_files)} GPKG files.', 'info')

        if not dl_files:
            self._log('Dự án không có dữ liệu GPKG hoặc JSON.', 'warn')
            return

        tmp_dir = tempfile.mkdtemp(prefix='lvt_pull_')

        for idx, f in enumerate(dl_files):
            self._log(f'  → Tải {f["name"]}...', 'info')
            QApplication.processEvents()

            data = self.client._api_download(f['id'])
            local_path = os.path.join(tmp_dir, f['name'])
            with open(local_path, 'wb') as fp:
                fp.write(data)

            # If ZIP, extract GPKG
            import zipfile as zf
            if f['name'].endswith('.gpkg.zip'):
                with zf.ZipFile(local_path, 'r') as z:
                    z.extractall(tmp_dir)
                gpkg_name = f['name'].replace('.zip', '')
                local_path = os.path.join(tmp_dir, gpkg_name)

            # Add to QGIS
            layer_name = f['name'].replace('.gpkg.zip', '').replace('.gpkg', '')
            display_name = f'{project_name} — {layer_name}'
            layer = QgsVectorLayer(local_path, display_name, 'ogr')
            if layer.isValid():
                QgsProject.instance().addMapLayer(layer)
                self._log(f'    ✅ Đã thêm {layer_name}', 'ok')
            else:
                self._log(f'    ❌ Lỗi mở {layer_name}', 'error')

            self.progress.setValue(int(((idx + 1) / len(dl_files)) * 100))
            QApplication.processEvents()

        self._log(f'Hoàn tất! Đã tải {len(dl_files)} layers.', 'ok')
        self.lbl_last_sync.setText(f'Lần đồng bộ cuối: {datetime.now().strftime("%H:%M:%S %d/%m/%Y")}')

    def _create_qgis_layer(self, name, geom_type, features, group_name=''):
        """Create a QgsVectorLayer from server feature data.

        Args:
            name: Layer name.
            geom_type: Server geometry type string.
            features: List of feature dicts from server.
            group_name: Optional group / project name prefix.

        Returns:
            QgsVectorLayer or None on failure.
        """
        qgis_geom = _server_geom_type_to_qgis_uri(geom_type)
        display_name = f'{group_name} — {name}' if group_name else name
        uri = f'{qgis_geom}?crs=EPSG:4326&index=yes'

        layer = QgsVectorLayer(uri, display_name, 'memory')
        if not layer.isValid():
            return None

        provider = layer.dataProvider()

        # Collect all attribute keys from features
        all_attr_keys = set()
        for feat_data in features:
            attrs_str = feat_data.get('attributes', '{}')
            try:
                attrs = json.loads(attrs_str) if isinstance(attrs_str, str) else (attrs_str or {})
            except (json.JSONDecodeError, TypeError):
                attrs = {}
            all_attr_keys.update(attrs.keys())

        # Always add remote_id field for tracking
        fields = [QgsField('remote_id', QVariant.String)]
        fields.append(QgsField('remote_layer_id', QVariant.String))
        fields.append(QgsField('remote_version', QVariant.Int))

        for key in sorted(all_attr_keys):
            fields.append(QgsField(key, QVariant.String))

        provider.addAttributes(fields)
        layer.updateFields()

        # Add features
        qgs_features = []
        for feat_data in features:
            coords_json = feat_data.get('coordinates_json', '[]')
            geom = _coords_json_to_qgs_geometry(coords_json, geom_type)

            attrs_str = feat_data.get('attributes', '{}')
            try:
                attrs = json.loads(attrs_str) if isinstance(attrs_str, str) else (attrs_str or {})
            except (json.JSONDecodeError, TypeError):
                attrs = {}

            qfeat = QgsFeature(layer.fields())
            if geom:
                qfeat.setGeometry(geom)

            # Set tracking attributes
            qfeat.setAttribute('remote_id', feat_data.get('id', ''))
            qfeat.setAttribute('remote_layer_id', feat_data.get('layer_id', ''))
            qfeat.setAttribute('remote_version', feat_data.get('version', 1))

            # Set user attributes
            for key in sorted(all_attr_keys):
                val = attrs.get(key, '')
                qfeat.setAttribute(key, str(val) if val is not None else '')

            qgs_features.append(qfeat)

        if qgs_features:
            provider.addFeatures(qgs_features)
            layer.updateExtents()

        return layer

    # -- Sync: Unified Google Drive ------------------------------------------

    def _on_sync_gdrive(self):
        """Unified sync for Google Drive — push + pull with project selection."""
        if not self.client.is_authenticated():
            QMessageBox.warning(self, tr('Not signed in'), tr('Please sign in to Google first.'))
            return

        from qgis.PyQt.QtWidgets import QDialog, QDialogButtonBox, QCheckBox, QListWidget, QListWidgetItem

        dlg = QDialog(self)
        dlg.setWindowTitle(tr('🔄 Sync Google Drive'))
        dlg.setMinimumWidth(420)
        lay = QVBoxLayout(dlg)

        # Section 1: Push current project?
        push_group = QGroupBox(tr('⬆ Push QGIS project to Google Drive'))
        push_lay = QVBoxLayout(push_group)
        self._sync_push_check = QCheckBox(tr('Push current project to Google Drive'))
        self._sync_push_check.setChecked(True)
        push_lay.addWidget(self._sync_push_check)
        lay.addWidget(push_group)

        # Section 2: Pull projects
        pull_group = QGroupBox(tr('⬇ Download projects from Google Drive'))
        pull_lay = QVBoxLayout(pull_group)

        self._sync_pull_list = QListWidget()
        self._sync_pull_list.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
        pull_lay.addWidget(QLabel(tr('Loading...')))
        pull_lay.addWidget(self._sync_pull_list)
        lay.addWidget(pull_group)

        # Load projects list
        try:
            projects = self.client.list_projects()
            # Replace "Loading" label
            pull_lay.itemAt(0).widget().setText(
                tr('Select projects to download ({n} projects):').format(n=len(projects))
            )
            for p in projects:
                item = QListWidgetItem()
                cb = QCheckBox(p['name'])
                item.setData(QtCompat.UserRole, p)
                self._sync_pull_list.addItem(item)
                self._sync_pull_list.setItemWidget(item, cb)
        except Exception as e:
            pull_lay.itemAt(0).widget().setText(f'Error: {e}')

        # Buttons
        buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
        buttons.button(QDialogButtonBox.StandardButton.Ok).setText(tr('🔄 Sync now'))
        buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(tr('Cancel'))
        buttons.accepted.connect(dlg.accept)
        buttons.rejected.connect(dlg.reject)
        lay.addWidget(buttons)

        if dlg.exec() != QDialog.DialogCode.Accepted:
            return

        # Execute push
        if self._sync_push_check.isChecked():
            self._log('=== Đẩy dự án lên Google Drive ===', 'info')
            self._on_push()

        # Execute pull for selected projects
        for i in range(self._sync_pull_list.count()):
            item = self._sync_pull_list.item(i)
            cb = self._sync_pull_list.itemWidget(item)
            if cb and cb.isChecked():
                p = item.data(QtCompat.UserRole)
                self._log(f'=== Tải dự án: {p["name"]} ===', 'info')
                self._download_project(p['id'], p['name'])

        self._on_refresh_projects()

    # -- Sync: Push ----------------------------------------------------------

    def _on_push(self):
        """Push current QGIS project to Google Drive as JSON."""
        if not self.client.is_authenticated():
            QMessageBox.warning(self, tr('Not signed in'), tr('Please sign in to Google first.'))
            self.tabs.setCurrentIndex(0)
            return

        project = QgsProject.instance()
        if not project.fileName():
            QMessageBox.warning(self, tr('No project'), tr('Please open a QGIS project first.'))
            return

        # Collect vector layers (allow empty ones too)
        vector_layers = [
            lyr for lyr in project.mapLayers().values()
            if isinstance(lyr, QgsVectorLayer)
        ]
        if not vector_layers:
            QMessageBox.information(self, tr('No data'), tr('No vector layers found.'))
            return

        project_name = os.path.splitext(os.path.basename(project.fileName()))[0]
        layer_info = '\n'.join(f'  • {lyr.name()} ({lyr.featureCount()} features)' for lyr in vector_layers)
        reply = QMessageBox.question(
            self, tr('Confirm'),
            tr('Push project "{name}" to Google Drive?').format(name=project_name)
            + f'\n\n{layer_info}',
            MsgButton.Yes | MsgButton.No, MsgButton.Yes
        )
        if reply != MsgButton.Yes:
            return

        self.btn_sync_gdrive.setEnabled(False)
        self.progress.setValue(0)
        from qgis.PyQt.QtWidgets import QApplication

        try:
            def _stable_id(seed):
                return hashlib.md5(seed.encode('utf-8'), usedforsecurity=False).hexdigest()[:15]

            self._log('Chuẩn bị dữ liệu...', 'info')
            QApplication.processEvents()

            json_files = {}

            # project.json
            json_files['project.json'] = json.dumps({
                'version': 1, 'app_version': '2.6.8',
                'exported_at': datetime.now().isoformat(),
                'project': {
                    'id': _stable_id(project_name), 'name': project_name,
                    'description': '', 'crs': project.crs().authid() or 'EPSG:4326',
                    'created_at': datetime.now().isoformat(),
                    'updated_at': datetime.now().isoformat(),
                },
            }, ensure_ascii=False).encode('utf-8')

            # layers + features
            layers_list, features_list = [], []
            for layer in vector_layers:
                layer_id = _stable_id(f'{project_name}/{layer.name()}')
                geom_type = _qgis_geom_type_to_server(layer.wkbType())
                style_config = {}
                renderer = layer.renderer()
                if renderer and renderer.symbol():
                    sym = renderer.symbol()
                    style_config = {
                        'color': sym.color().name() if sym.color() else '#3388ff',
                        'opacity': sym.opacity() if hasattr(sym, 'opacity') else 1.0,
                    }
                tree_layer = project.layerTreeRoot().findLayer(layer.id())
                is_vis = 1 if (tree_layer and tree_layer.isVisible()) else 0
                layers_list.append({
                    'id': layer_id, 'name': layer.name(),
                    'geometry_type': geom_type, 'style_config': style_config,
                    'z_order': 0, 'is_visible': is_vis,
                })
                for feat in layer.getFeatures():
                    coords = _qgs_geometry_to_coords_json(feat.geometry(), geom_type)
                    attrs = {}
                    for field in feat.fields():
                        val = feat.attribute(field.name())
                        if val is not None:
                            attrs[field.name()] = str(val)
                    features_list.append({
                        'id': _stable_id(f'{project_name}/{layer.name()}/feat_{feat.id()}'), 'layer_id': layer_id,
                        'geometry': coords,
                        'properties': json.dumps(attrs, ensure_ascii=False),
                        'created_at': datetime.now().isoformat(),
                        'updated_at': datetime.now().isoformat(), 'version': 1,
                    })

            json_files['layers.json'] = json.dumps({
                'version': 1, 'exported_at': datetime.now().isoformat(), 'layers': layers_list
            }, ensure_ascii=False).encode('utf-8')
            json_files['features.json'] = json.dumps({
                'version': 1, 'exported_at': datetime.now().isoformat(), 'features': features_list
            }, ensure_ascii=False).encode('utf-8')

            try:
                layouts_data = self._extract_layouts()
                if layouts_data:
                    json_files['layouts.json'] = json.dumps({
                        'version': 1, 'exported_at': datetime.now().isoformat(), 'layouts': layouts_data
                    }, ensure_ascii=False).encode('utf-8')
            except Exception:  # nosec B110
                pass  # nosec B110

            self._log(f'Dữ liệu: {len(layers_list)} layers, {len(features_list)} features', 'ok')
            self.progress.setValue(15)
            QApplication.processEvents()

            # Find/create folders
            self._log('① Tìm thư mục LVTField...', 'info')
            QApplication.processEvents()
            root_id = self.client._find_or_create_folder('LVTField')
            self._log('  ✅ OK', 'ok')
            self.progress.setValue(25)
            QApplication.processEvents()

            self._log('② Tìm thư mục projects...', 'info')
            QApplication.processEvents()
            projects_id = self.client._find_or_create_folder('projects', root_id)
            self._log('  ✅ OK', 'ok')
            self.progress.setValue(30)
            QApplication.processEvents()

            self._log(f'③ Tìm thư mục {project_name}...', 'info')
            QApplication.processEvents()
            folder_id = self.client._find_or_create_folder(project_name, projects_id)
            self._log('  ✅ OK', 'ok')
            self.progress.setValue(35)
            QApplication.processEvents()

            # Upload each JSON file
            file_names = list(json_files.keys())
            for i, fname in enumerate(file_names):
                size_kb = len(json_files[fname]) / 1024
                self._log(f'④ Upload {fname} ({size_kb:.0f} KB)...', 'info')
                QApplication.processEvents()
                self.client.upload_file(folder_id, fname, json_files[fname], 'application/json')
                pct = 40 + int((i + 1) / len(file_names) * 55)
                self.progress.setValue(pct)
                self._log(f'  ✅ {fname}', 'ok')
                QApplication.processEvents()

            # Done
            self.progress.setValue(100)
            self._log(f'✅ Push complete! "{project_name}" uploaded to Google Drive.', 'ok')
            QMessageBox.information(
                self, tr('Success'),
                f'Đã đẩy dự án "{project_name}" lên Google Drive!')

        except Exception as exc:
            self._log(f'Error: {exc}', 'error')
            self._log(traceback.format_exc(), 'error')
            QMessageBox.critical(self, tr('Error'), f'Upload failed:\n{exc}')
        finally:
            self.btn_sync_gdrive.setEnabled(True)

    def _extract_layouts(self):
        """Extract print layouts from QGIS project as JSON-serializable dicts."""
        layouts = []
        project = QgsProject.instance()
        manager = project.layoutManager()
        if not manager:
            return layouts

        for layout in manager.layouts():
            layout_data = {
                'name': layout.name(),
                'page_count': layout.pageCollection().pageCount() if hasattr(layout, 'pageCollection') else 1,
            }
            # Extract page size from first page
            try:
                page = layout.pageCollection().page(0)
                if page:
                    size = page.pageSize()
                    layout_data['width_mm'] = size.width()
                    layout_data['height_mm'] = size.height()
            except Exception:  # nosec B110
                pass  # nosec B110
            layouts.append(layout_data)

        return layouts

    def _on_delete_gdrive_project(self):
        """Delete selected project from Google Drive."""
        if not self.client.is_authenticated():
            QMessageBox.warning(self, tr('Not signed in'), tr('Please sign in first.'))
            return
        selected = self.tbl_projects.selectedItems()
        if not selected:
            QMessageBox.warning(self, tr('Nothing selected'), tr('Please select a project to delete.'))
            return
        row = selected[0].row()
        project_name = self.tbl_projects.item(row, 0).text()
        project_id = self.tbl_projects.item(row, 3).text()
        reply = QMessageBox.question(
            self, tr('Confirm delete'),
            tr('Are you sure you want to delete "{name}" from Google Drive?\n'
               'All related data will be permanently deleted!').format(name=project_name),
            MsgButton.Yes | MsgButton.No, MsgButton.No
        )
        if reply != MsgButton.Yes:
            return
        try:
            self.client.delete_project(project_id)
            self._log(tr('Deleted project "{name}"').format(name=project_name), 'ok')
            self._on_refresh_projects()  # Refresh the list
        except Exception as exc:
            QMessageBox.critical(self, 'Lỗi', f'Xóa thất bại:\n{exc}')

    def _on_pull(self):
        """Pull project from Google Drive."""
        if not self.client.is_authenticated():
            QMessageBox.warning(self, 'Chưa đăng nhập', 'Vui lòng đăng nhập trước.')
            self.tabs.setCurrentIndex(0)
            return

        try:
            projects = self.client.list_projects()
            if not projects:
                QMessageBox.information(self, 'Trống', 'Không có dự án nào trên Google Drive.')
                return

            names = [p['name'] for p in projects]
            choice, ok = QInputDialog.getItem(self, 'Chọn dự án', 'Dự án trên Google Drive:', names, 0, False)
            if not ok:
                return

            selected = next(p for p in projects if p['name'] == choice)
            self._download_project(selected['id'], selected['name'])

        except Exception as exc:
            self._log(f'Lỗi: {exc}', 'error')
            QMessageBox.critical(self, 'Lỗi', f'Lỗi tải về:\n{exc}')

    def _export_log(self, message, level='info'):
        """Append message to export log."""
        timestamp = datetime.now().strftime('%H:%M:%S')
        colors = {
            'info': '#d4d4d4', 'ok': '#4EC9B0',
            'warn': '#DCDCAA', 'error': '#F44747',
        }
        color = colors.get(level, colors['info'])
        self.txt_export_log.append(
            f'<span style="color:#888">[{timestamp}]</span> '
            f'<span style="color:{color}">{message}</span>'
        )

    def _on_browse_export_dir(self):
        """Open folder browser for export directory."""
        from qgis.PyQt.QtWidgets import QFileDialog
        folder = QFileDialog.getExistingDirectory(
            self, tr('Choose export folder'),
            self.txt_export_dir.text() or os.path.expanduser('~')
        )
        if folder:
            self.txt_export_dir.setText(folder)

    def _validate_project(self) -> list:
        """Pre-flight validation before export. Returns list of (level, message) tuples."""
        issues = []
        project = QgsProject.instance()

        if not project.fileName():
            issues.append(('error', 'Dự án chưa được lưu. Hãy lưu dự án trước khi xuất.'))
            return issues

        layers = project.mapLayers().values()
        if not layers:
            issues.append(('error', 'Dự án không có lớp dữ liệu nào.'))
            return issues

        for layer in layers:
            if not isinstance(layer, QgsVectorLayer):
                continue
            if not layer.isValid():
                issues.append(('warning', f'Lớp "{layer.name()}" không hợp lệ — sẽ bị bỏ qua.'))
            elif layer.featureCount() == 0:
                issues.append(('info', f'Lớp "{layer.name()}" không có đối tượng.'))
            # Check for unsaved edits
            if layer.isEditable() and layer.isModified():
                issues.append(('warning', f'Lớp "{layer.name()}" có thay đổi chưa lưu!'))
            # Check OneDrive dehydration
            source = layer.source().split('|')[0]
            if os.path.exists(source) and is_onedrive_cloud_file(source):
                issues.append(('warning', f'Lớp "{layer.name()}" là file OneDrive chưa tải về.'))

        return issues

    def _get_vector_layers(self):
        """Get all vector layers with features from current QGIS project."""
        return [
            lyr for lyr in QgsProject.instance().mapLayers().values()
            if isinstance(lyr, QgsVectorLayer) and lyr.featureCount() > 0
        ]

    def _refresh_layer_list(self):
        """Refresh the layer selection tree widget with current project layers."""
        self.tree_layers.blockSignals(True)
        self.tree_layers.clear()
        geom_names = {
            QgsWkbTypes.GeometryType.PointGeometry: 'Point',
            QgsWkbTypes.GeometryType.LineGeometry: 'Line',
            QgsWkbTypes.GeometryType.PolygonGeometry: 'Polygon',
        }
        project = QgsProject.instance()
        _ = project.layerTreeRoot()

        for layer in project.mapLayers().values():
            if not isinstance(layer, QgsVectorLayer):
                continue
            geom_type = geom_names.get(layer.geometryType(), '?')
            feat_count = layer.featureCount()
            item = QTreeWidgetItem()
            item.setText(0, layer.name())
            item.setText(1, geom_type)
            item.setText(2, str(feat_count))
            item.setCheckState(0, QtCompat.Checked if feat_count > 0 else QtCompat.Unchecked)
            item.setData(0, QtCompat.UserRole, layer.id())
            # Gray out empty layers
            if feat_count == 0:
                from qgis.PyQt.QtGui import QBrush, QColor
                gray_brush = QBrush(QColor(Qt.GlobalColor.gray))
                for col in range(3):
                    item.setForeground(col, gray_brush)
            self.tree_layers.addTopLevelItem(item)

        self.tree_layers.blockSignals(False)
        self._update_layer_summary()

    def _update_layer_summary(self):
        """Update the selection summary label."""
        total = self.tree_layers.topLevelItemCount()
        checked = sum(
            1 for i in range(total)
            if self.tree_layers.topLevelItem(i).checkState(0) == QtCompat.Checked
        )
        total_features = 0
        for i in range(total):
            item = self.tree_layers.topLevelItem(i)
            if item.checkState(0) == QtCompat.Checked:
                try:
                    total_features += int(item.text(2))
                except ValueError:
                    pass  # nosec B110
        self.lbl_layer_summary.setText(
            tr('Selected {checked}/{total} layers — {features} features').format(
                checked=checked, total=total, features=total_features)
        )

    def _on_select_all_layers(self):
        """Check all layers."""
        self.tree_layers.blockSignals(True)
        for i in range(self.tree_layers.topLevelItemCount()):
            self.tree_layers.topLevelItem(i).setCheckState(0, QtCompat.Checked)
        self.tree_layers.blockSignals(False)
        self._update_layer_summary()

    def _on_deselect_all_layers(self):
        """Uncheck all layers."""
        self.tree_layers.blockSignals(True)
        for i in range(self.tree_layers.topLevelItemCount()):
            self.tree_layers.topLevelItem(i).setCheckState(0, QtCompat.Unchecked)
        self.tree_layers.blockSignals(False)
        self._update_layer_summary()

    def _on_visible_only_layers(self):
        """Check only currently visible layers."""
        self.tree_layers.blockSignals(True)
        project = QgsProject.instance()
        root = project.layerTreeRoot()
        for i in range(self.tree_layers.topLevelItemCount()):
            item = self.tree_layers.topLevelItem(i)
            layer_id = item.data(0, QtCompat.UserRole)
            node = root.findLayer(layer_id)
            is_visible = node is not None and node.isVisible()
            item.setCheckState(0, QtCompat.Checked if is_visible else QtCompat.Unchecked)
        self.tree_layers.blockSignals(False)
        self._update_layer_summary()

    def _on_tab_changed(self, index):
        """Refresh layer list when export tab is selected."""
        # Export tab is index 2
        if index == 2:
            self._refresh_layer_list()

    def _get_selected_layers(self):
        """Get only the checked layers from the tree widget.

        Returns:
            List of QgsVectorLayer that are checked in the export tree.
        """
        project = QgsProject.instance()
        selected = []
        for i in range(self.tree_layers.topLevelItemCount()):
            item = self.tree_layers.topLevelItem(i)
            if item.checkState(0) == QtCompat.Checked:
                layer_id = item.data(0, QtCompat.UserRole)
                layer = project.mapLayer(layer_id)
                if layer and isinstance(layer, QgsVectorLayer):
                    selected.append(layer)
        return selected

    def _on_export_gpkg(self):
        """Export all vector layers to a single GPKG file."""
        from qgis.PyQt.QtWidgets import QApplication

        export_dir = self.txt_export_dir.text().strip()
        if not export_dir or not os.path.isdir(export_dir):
            QMessageBox.warning(self, tr('Error'), tr('Please select a valid export folder.'))
            return

        layers = self._get_selected_layers()
        if not layers:
            QMessageBox.warning(self, tr('No layer selected'),
                                tr('Please select at least one layer to export.\n\nTick the layers in the list above.'))
            return

        project_name = QgsProject.instance().baseName() or 'LVTField_Export'
        # Sanitize filename
        safe_name = ''.join(c if c.isalnum() or c in ('_', '-') else '_' for c in project_name)
        timestamp = datetime.now().strftime('%Y%m%d_%H%M')
        output_path = os.path.join(export_dir, f'{safe_name}_{timestamp}.gpkg')

        save_styles = self.chk_save_styles.isChecked()

        # Pre-flight validation
        issues = self._validate_project()
        errors = [msg for lvl, msg in issues if lvl == 'error']
        warnings = [msg for lvl, msg in issues if lvl == 'warning']
        if errors:
            QMessageBox.critical(self, tr('Cannot export'),
                                 'Errors:\n• ' + '\n• '.join(errors))
            return
        if warnings:
            reply = QMessageBox.warning(
                self, tr('Warning'),
                tr('Issues found:\n• {issues}\n\nContinue anyway?').format(
                    issues='\n• '.join(warnings)),
                MsgButton.Yes | MsgButton.No, MsgButton.No
            )
            if reply != MsgButton.Yes:
                return

        self._export_log(f'Bắt đầu xuất GPKG: {len(layers)} layers', 'info')
        self.export_progress.setValue(0)
        self.export_progress.setFormat('Đang xuất GPKG...')
        QApplication.processEvents()

        try:
            total_features = 0
            for idx, layer in enumerate(layers):
                layer_name = layer.name()
                self._export_log(f'  → Xuất layer: {layer_name} ({layer.featureCount()} features)', 'info')
                QApplication.processEvents()

                options = QgsVectorFileWriter.SaveVectorOptions()
                options.driverName = 'GPKG'
                options.layerName = layer_name
                if idx > 0:
                    options.actionOnExistingFile = QgsVectorFileWriter.ActionOnExistingFile.CreateOrOverwriteLayer

                error_code, error_msg, new_file, new_layer = QgsVectorFileWriter.writeAsVectorFormatV3(
                    layer, output_path, QgsCoordinateTransformContext(), options
                )

                if error_code != QgsVectorFileWriter.WriterError.NoError:
                    self._export_log(f'    ❌ Lỗi: {error_msg}', 'error')
                    continue

                total_features += layer.featureCount()
                # WAL checkpoint before opening for style save
                gpkg_wal_checkpoint(output_path)
                self._export_log('    ✅ OK', 'ok')

                # Save style to GPKG if requested
                if save_styles:
                    try:
                        # Reload layer from GPKG to save style
                        temp_layer = QgsVectorLayer(
                            f'{output_path}|layername={layer_name}', layer_name, 'ogr'
                        )
                        if temp_layer.isValid():
                            # Copy renderer and labeling from original
                            temp_layer.setRenderer(layer.renderer().clone())
                            if layer.labeling():
                                temp_layer.setLabeling(layer.labeling().clone())
                                temp_layer.setLabelsEnabled(layer.labelsEnabled())
                            # Save style to GPKG database
                            temp_layer.saveStyleToDatabase(
                                layer_name, 'LVTSync export', True, ''
                            )
                            self._export_log('    ✅ Style saved', 'ok')
                            # PROPER CLEANUP: disconnect data provider before deleting
                            temp_layer.setDataSource('', '', '')
                        del temp_layer
                        import gc
                        gc.collect()
                    except Exception as style_err:
                        self._export_log(f'    ⚠ Style lỗi: {style_err}', 'warn')

                progress = int(((idx + 1) / len(layers)) * 100)
                self.export_progress.setValue(progress)
                self.export_progress.setFormat(f'Layer {idx + 1}/{len(layers)}: {layer_name} — %p%')
                QApplication.processEvents()

            file_size = os.path.getsize(output_path) / (1024 * 1024)
            self._export_log(
                f'Hoàn tất! {len(layers)} layers, {total_features} features.', 'ok'
            )
            self._export_log(f'File: {output_path} ({file_size:.1f} MB)', 'ok')

            QMessageBox.information(
                self, tr('Export GPKG successful'),
                tr('\u2705 Export successful!\n\n'
                   '• {n} layers, {f} features\n'
                   '• File: {fname}\n'
                   '• Size: {size:.1f} MB\n\n'
                   '📂 {path}').format(
                    n=len(layers), f=total_features,
                    fname=os.path.basename(output_path),
                    size=file_size, path=output_path)
            )

        except Exception as exc:
            self._export_log(f'GPKG export error: {exc}', 'error')
            QMessageBox.critical(self, tr('Error'), f'GPKG export failed:\n{exc}')

    def _on_export_zip(self):
        """Export entire QGIS project as ZIP (.qgs + .gpkg files)."""
        from qgis.PyQt.QtWidgets import QApplication

        export_dir = self.txt_export_dir.text().strip()
        if not export_dir or not os.path.isdir(export_dir):
            QMessageBox.warning(self, tr('Error'), tr('Please select a valid export folder.'))
            return

        layers = self._get_selected_layers()
        if not layers:
            QMessageBox.warning(self, tr('No layer selected'),
                                tr('Please select at least one layer to export.\n\nTick the layers in the list above.'))
            return

        project_name = QgsProject.instance().baseName() or 'LVTField_Project'
        safe_name = ''.join(c if c.isalnum() or c in ('_', '-') else '_' for c in project_name)
        timestamp = datetime.now().strftime('%Y%m%d_%H%M')
        zip_path = os.path.join(export_dir, f'{safe_name}_{timestamp}.zip')

        # Pre-flight validation
        issues = self._validate_project()
        errors = [msg for lvl, msg in issues if lvl == 'error']
        warnings = [msg for lvl, msg in issues if lvl == 'warning']
        if errors:
            QMessageBox.critical(self, tr('Cannot export'),
                                 'Errors:\n• ' + '\n• '.join(errors))
            return
        if warnings:
            reply = QMessageBox.warning(
                self, tr('Warning'),
                tr('Issues found:\n• {issues}\n\nContinue anyway?').format(
                    issues='\n• '.join(warnings)),
                MsgButton.Yes | MsgButton.No, MsgButton.No
            )
            if reply != MsgButton.Yes:
                return

        self._export_log(f'Bắt đầu xuất dự án ZIP: {len(layers)} layers', 'info')
        self.export_progress.setValue(0)
        self.export_progress.setFormat('Đang xuất ZIP...')
        QApplication.processEvents()

        try:
            with tempfile.TemporaryDirectory(prefix='lvtsync_') as tmpdir:
                total_features = 0
                gpkg_files = []

                # Step 1: Export each layer as separate GPKG with styles
                for idx, layer in enumerate(layers):
                    layer_name = layer.name()
                    # Sanitize layer name for filename
                    safe_layer = ''.join(
                        c if c.isalnum() or c in ('_', '-') else '_'
                        for c in layer_name
                    )
                    gpkg_path = os.path.join(tmpdir, f'{safe_layer}.gpkg')

                    self._export_log(f'  → Xuất layer: {layer_name}', 'info')
                    QApplication.processEvents()

                    options = QgsVectorFileWriter.SaveVectorOptions()
                    options.driverName = 'GPKG'
                    options.layerName = layer_name

                    error_code, error_msg, _, _ = QgsVectorFileWriter.writeAsVectorFormatV3(
                        layer, gpkg_path, QgsCoordinateTransformContext(), options
                    )

                    if error_code != QgsVectorFileWriter.WriterError.NoError:
                        self._export_log(f'    ❌ Lỗi: {error_msg}', 'error')
                        continue

                    # WAL checkpoint before style save
                    gpkg_wal_checkpoint(gpkg_path)

                    # Save style
                    try:
                        temp_layer = QgsVectorLayer(
                            f'{gpkg_path}|layername={layer_name}', layer_name, 'ogr'
                        )
                        if temp_layer.isValid():
                            temp_layer.setRenderer(layer.renderer().clone())
                            if layer.labeling():
                                temp_layer.setLabeling(layer.labeling().clone())
                                temp_layer.setLabelsEnabled(layer.labelsEnabled())
                            temp_layer.saveStyleToDatabase(
                                layer_name, 'LVTSync export', True, ''
                            )
                            # PROPER CLEANUP: disconnect data provider before deleting
                            temp_layer.setDataSource('', '', '')
                        del temp_layer
                        import gc
                        gc.collect()
                    except Exception:  # nosec B110
                        pass  # nosec B110

                    gpkg_files.append((gpkg_path, f'{safe_layer}.gpkg'))
                    total_features += layer.featureCount()

                    progress = int(((idx + 1) / len(layers)) * 50)  # 0-50%
                    self.export_progress.setValue(progress)
                    self.export_progress.setFormat(f'Layer {idx + 1}/{len(layers)}: {layer_name} — %p%')
                    QApplication.processEvents()

                if not gpkg_files:
                    self._export_log('Không xuất được layer nào.', 'error')
                    return

                # Step 2: Save .qgs project file with relative datasources
                self._export_log('Tạo file project.qgs...', 'info')
                QApplication.processEvents()

                qgs_path = os.path.join(tmpdir, 'project.qgs')
                # Create a temporary project with GPKG references
                self._create_portable_qgs(qgs_path, layers, gpkg_files)
                self.export_progress.setValue(70)
                self.export_progress.setFormat('Tạo project.qgs... — %p%')

                # Step 3: Pack everything into ZIP
                self._export_log('Đóng gói ZIP...', 'info')
                QApplication.processEvents()

                # WAL checkpoint all GPKGs before zipping
                for gpkg_path_chk, _ in gpkg_files:
                    gpkg_wal_checkpoint(gpkg_path_chk)

                with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
                    zf.write(qgs_path, 'project.qgs')
                    for gpkg_path, gpkg_name in gpkg_files:
                        zf.write(gpkg_path, gpkg_name)

                self.export_progress.setValue(100)
                self.export_progress.setFormat('Hoàn tất! — %p%')

            file_size = os.path.getsize(zip_path) / (1024 * 1024)
            self._export_log(
                f'Hoàn tất! {len(gpkg_files)} layers, {total_features} features.', 'ok'
            )
            self._export_log(f'File: {zip_path} ({file_size:.1f} MB)', 'ok')

            QMessageBox.information(
                self, tr('Export ZIP successful'),
                tr('\u2705 Project exported successfully!\n\n'
                   '• {n} layers, {f} features\n'
                   '• File: {fname}\n'
                   '• Size: {size:.1f} MB\n\n'
                   '📱 Copy this file to phone \u2192 LVTField \u2192 Import ZIP\n\n'
                   '📂 {path}').format(
                    n=len(gpkg_files), f=total_features,
                    fname=os.path.basename(zip_path),
                    size=file_size, path=zip_path)
            )

        except Exception as exc:
            self._export_log(f'ZIP export error: {exc}', 'error')
            import traceback as tb
            self._export_log(tb.format_exc(), 'error')
            QMessageBox.critical(self, tr('Error'), f'ZIP export failed:\n{exc}')

    def _create_portable_qgs(self, qgs_path, layers, gpkg_files):
        """Create a .qgs project file with relative paths to GPKG files.

        Args:
            qgs_path: Output .qgs file path.
            layers: Original QGIS layers.
            gpkg_files: List of (abs_path, relative_name) tuples.
        """
        # Save current project state
        original_project = QgsProject.instance()

        # Create a new temporary project
        temp_project = QgsProject()
        temp_project.setTitle(original_project.title() or original_project.baseName())

        for layer, (gpkg_abs, gpkg_rel) in zip(layers, gpkg_files):
            layer_name = layer.name()
            # Create a layer pointing to the GPKG with relative path
            uri = f'./{gpkg_rel}|layername={layer_name}'
            temp_layer = QgsVectorLayer(uri, layer_name, 'ogr')

            if not temp_layer.isValid():
                # Fallback: use absolute path (will be fixed after)
                temp_layer = QgsVectorLayer(
                    f'{gpkg_abs}|layername={layer_name}', layer_name, 'ogr'
                )

            if temp_layer.isValid():
                # Copy renderer
                if layer.renderer():
                    temp_layer.setRenderer(layer.renderer().clone())
                # Copy labeling
                if layer.labeling():
                    temp_layer.setLabeling(layer.labeling().clone())
                    temp_layer.setLabelsEnabled(layer.labelsEnabled())
                temp_project.addMapLayer(temp_layer)

        # Write project
        temp_project.write(qgs_path)

        # Post-process: ensure relative paths in .qgs XML
        try:
            with open(qgs_path, 'r', encoding='utf-8') as f:
                content = f.read()
            # Replace absolute temp paths with relative
            tmpdir = os.path.dirname(qgs_path)
            content = content.replace(tmpdir.replace('\\', '/') + '/', './')
            content = content.replace(tmpdir + '\\', './')
            content = content.replace(tmpdir + '/', './')
            with open(qgs_path, 'w', encoding='utf-8') as f:
                f.write(content)
        except (IOError, OSError):
            pass  # relative path fixup is best-effort

        temp_project.removeAllMapLayers()
        del temp_project
        import gc
        gc.collect()

    # ------------------------------------------------------------------
    # i18n: Live language switching
    # ------------------------------------------------------------------

    def _retranslate_ui(self, _lang=None):
        """Update all translatable widget texts when language changes.

        Called once at startup (via language_signals connect) and again
        whenever the user switches language in About → Settings.
        """
        self.setWindowTitle(tr('LVT Sync — Google Drive & Export'))

        # Tab labels
        self.tabs.setTabText(0, tr('☁️ Google'))
        self.tabs.setTabText(1, tr('📤 Export'))

        # --- Google tab ---
        # Login buttons
        if self.btn_login.isEnabled():
            self.btn_login.setText(tr('🔑 Sign in with Google'))
        self.btn_logout.setText(tr('Sign out'))

        # Projects section
        self.btn_refresh_projects.setText(tr('🔄 Refresh'))
        self.btn_delete_gdrive_project.setText(tr('🗑️ Delete project'))
        self.tbl_projects.setHorizontalHeaderLabels([
            tr('Project name'), tr('Description'), tr('Layers'), tr('ID')
        ])
        if 'Press' in self.lbl_projects_info.text() or 'Nhấn' in self.lbl_projects_info.text():
            self.lbl_projects_info.setText(tr('Press "Refresh" to load projects.'))

        # Sync button / last sync label
        self.btn_sync_gdrive.setText(tr('🔄 Sync Google Drive'))
        if self.lbl_last_sync.text() in ('Last sync: —', 'Lần đồng bộ cuối: —'):
            self.lbl_last_sync.setText(tr('Last sync: —'))

        # Auth status (only update if not mid-login)
        self._update_auth_ui()

        # --- Export tab ---
        self.txt_export_dir.setPlaceholderText(tr('Choose export folder...'))
        self.btn_browse_export.setText(tr('Browse...'))
        self.btn_select_all.setText(tr('Select All'))
        self.btn_deselect_all.setText(tr('Deselect All'))
        self.btn_visible_only.setText(tr('Visible only'))
        self.btn_refresh_layers.setToolTip(tr('Refresh layer list'))
        self.tree_layers.setHeaderLabels([tr('Layer'), tr('Type'), tr('Features')])
        self.chk_save_styles.setText(tr('Save styles (labels, colours, line styles)'))
        self.btn_export_gpkg.setText(tr('📦 Export GPKG'))
        self.btn_export_zip.setText(tr('📦 Export ZIP project'))

        # Update selection summary
        self._update_layer_summary()

    def closeEvent(self, event):
        """Disconnect language signal to avoid dangling connections."""
        try:
            language_signals.language_changed.disconnect(self._retranslate_ui)
        except (RuntimeError, TypeError):
            pass  # already disconnected
        super().closeEvent(event)
