# -*- coding: utf-8 -*-
"""LVT4U Package Map — Portable project packager.

Inspired by the Project Packager plugin (Tarot Osuji, GPL-2.0).
Simplified approach: copies all data files via GDAL, remaps layer
sources using QgsProviderRegistry, and writes a portable .qgz project.

UI: Two tabs — "Package" and "Extract SHP" — with full i18n support.
"""
import os
import shutil

from osgeo import gdal
from qgis.PyQt.QtCore import Qt, QDir, QUrl
from ..shared.qt_compat import QtCompat, MsgButton
from qgis.PyQt.QtGui import QFont, QIcon
from qgis.PyQt.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
    QLineEdit, QFileDialog, QMessageBox, QFormLayout,
    QProgressDialog, QToolButton, QRadioButton,
    QCheckBox, QGroupBox, QGridLayout, QTabWidget, QWidget,
)
from qgis.core import (
    QgsApplication, QgsProject, QgsProviderRegistry,
    QgsDataProvider, QgsVectorDataProvider, QgsVectorLayer,
    QgsRenderContext, QgsLayoutItemPicture,
    QgsSymbolLayerUtils,
)
from ..shared.i18n import tr, current_language, language_signals


# =====================================================================
# Symbol / image path utilities (handles SVG, raster fills, layout pics)
# =====================================================================

# Stock SVG paths shipped with QGIS — we skip these
_stock_svgs = list(
    map(lambda x: QDir(x).canonicalPath(), QgsSymbolLayerUtils.listSvgFiles())
)


def _symbol_layers_from_symbol(symbol):
    """Recursively collect all symbol layers from a symbol."""
    slyrs = []
    for slyr in symbol.symbolLayers():
        sub = slyr.subSymbol()
        if sub:
            slyrs.extend(_symbol_layers_from_symbol(sub))
        slyrs.append(slyr)
    return slyrs


def _get_path(slyr):
    """Get the external file path from a symbol layer."""
    for getter in ('path', 'svgFilePath', 'imageFilePath'):
        try:
            return getattr(slyr, getter)()
        except AttributeError:
            pass  # nosec B110
    return None


def _set_path(slyr, new_path):
    """Set the external file path on a symbol layer."""
    for setter in ('setPath', 'setSvgFilePath', 'setImageFilePath'):
        try:
            getattr(slyr, setter)(new_path)
            return
        except AttributeError:
            pass  # nosec B110


def _collect_symbol_paths(project, context):
    """Map: symbol_layer_or_layout_item → absolute file path."""
    result = {}

    # Symbol layers in map layers
    for lyr in project.mapLayers().values():
        try:
            syms = lyr.renderer().symbols(context)
        except AttributeError:
            continue
        for sym in syms:
            for slyr in _symbol_layers_from_symbol(sym):
                raw = _get_path(slyr)
                path = QDir(raw).canonicalPath() if raw else ''
                if path and os.path.exists(path) and path not in _stock_svgs:
                    result[slyr] = path

    # Layout items (pictures + shape symbols)
    for layout in project.layoutManager().printLayouts():
        model = layout.itemsModel()
        for row in range(model.rowCount()):
            item = model.itemFromIndex(model.index(row, 0))
            if isinstance(item, QgsLayoutItemPicture):
                raw = item.picturePath()
                path = QDir(raw).canonicalPath() if raw else ''
                if path and os.path.exists(path) and path not in _stock_svgs:
                    result[item] = path
            else:
                try:
                    sym = item.symbol()
                except AttributeError:
                    continue
                for slyr in _symbol_layers_from_symbol(sym):
                    raw = _get_path(slyr)
                    path = QDir(raw).canonicalPath() if raw else ''
                    if path and os.path.exists(
                            path) and path not in _stock_svgs:
                        result[slyr] = path

    return result


# =====================================================================
# Helpers
# =====================================================================

def _is_in_dir(parent, child):
    """Check if child path is inside parent directory."""
    parent = os.path.abspath(parent)
    child = os.path.abspath(child)
    try:
        return os.path.commonpath([parent, child]) == parent
    except ValueError:
        return False


def _safe_dst(dst_dir, src_path):
    """Return a collision-free destination path inside dst_dir.

    If <dst_dir>/<basename> already exists and has a different source,
    append _1, _2, … to the stem to avoid overwriting a different file.
    """
    base = os.path.basename(src_path)
    candidate = os.path.join(dst_dir, base)
    if not os.path.exists(candidate):
        return candidate
    # Already same file (copied earlier) — return as-is
    if os.path.isfile(candidate):
        try:
            if os.path.getsize(candidate) == os.path.getsize(src_path):
                return candidate
        except OSError:
            pass  # nosec B110
    stem, ext = os.path.splitext(base)
    counter = 1
    while True:
        candidate = os.path.join(dst_dir, f"{stem}_{counter}{ext}")
        if not os.path.exists(candidate):
            return candidate
        counter += 1


def _get_source_info(layer):
    """Decode a layer's data provider URI into (path, layerName, options).

    Returns None if the layer is not file-based.
    """
    dp = layer.dataProvider()
    if dp is None:
        return None
    reg = QgsProviderRegistry.instance()
    parts = reg.decodeUri(dp.name(), layer.source())
    path = parts.get('path')
    if path:
        path = QDir(path).canonicalPath()
    if not path:
        return None
    layer_name = parts.get('layerName')
    return path, layer_name, None


# =====================================================================
# Dialog — two tabs: Package | Extract SHP
# =====================================================================

class PackageMapDialog(QDialog):
    """Bilingual project packager — Package and Extract SHP tabs."""

    def __init__(self, iface, parent=None):
        super().__init__(parent or iface.mainWindow())
        self.iface = iface
        self.setWindowTitle("LVT4U — Package Map")
        self.setWindowFlags(
            self.windowFlags() & ~QtCompat.WindowContextHelpButtonHint)
        self._build_ui()
        # Live language switch
        language_signals.language_changed.connect(self._retranslate)

    # ------------------------------------------------------------------
    # UI construction
    # ------------------------------------------------------------------
    def _build_ui(self):
        root = QVBoxLayout(self)
        root.setContentsMargins(10, 10, 10, 10)
        root.setSpacing(6)

        # ── Tab widget ─────────────────────────────────────────────────
        self.tabs = QTabWidget()
        self.tabs.setDocumentMode(True)

        # Tab 1 — Package
        self._tab_pkg = QWidget()
        self._build_tab_package(self._tab_pkg)
        self.tabs.addTab(self._tab_pkg, "")

        # Tab 2 — Extract SHP
        self._tab_ext = QWidget()
        self._build_tab_extract(self._tab_ext)
        self.tabs.addTab(self._tab_ext, "")

        root.addWidget(self.tabs)

        # ── Bottom button row (Help / Close) ───────────────────────────
        btn_row = QHBoxLayout()
        self.btn_help = QPushButton("❓ Help")
        self.btn_help.setMinimumHeight(36)
        self.btn_help.setStyleSheet(
            "QPushButton{background:#ff8f00;color:#fff;font-weight:bold;"
            "padding:6px 14px;border-radius:4px}"
            "QPushButton:hover{background:#ffa000}"
        )
        self.btn_help.clicked.connect(self._show_help)
        btn_row.addWidget(self.btn_help)
        btn_row.addStretch()

        self.btn_close = QPushButton("")
        self.btn_close.setMinimumHeight(36)
        self.btn_close.clicked.connect(self.close)
        btn_row.addWidget(self.btn_close)
        root.addLayout(btn_row)

        self._retranslate()

    # ── Tab 1: Package ─────────────────────────────────────────────────
    def _build_tab_package(self, tab):
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.setSpacing(6)

        # Description
        self.pkg_desc = QLabel()
        self.pkg_desc.setWordWrap(True)
        self.pkg_desc.setStyleSheet("color: #555; margin-bottom: 6px;")
        layout.addWidget(self.pkg_desc)

        # Destination folder
        form = QFormLayout()
        form.setContentsMargins(0, 0, 0, 0)
        row = QHBoxLayout()
        self.dir_edit = QLineEdit()
        self.dir_edit.setReadOnly(True)
        self.dir_edit.setMinimumWidth(400)
        row.addWidget(self.dir_edit)
        btn_browse = QToolButton()
        btn_browse.setIcon(
            QIcon(':/images/themes/default/mActionFileOpen.svg'))
        btn_browse.clicked.connect(self._browse)
        row.addWidget(btn_browse)
        self.pkg_dest_label = QLabel("")
        form.addRow(self.pkg_dest_label, row)
        layout.addLayout(form)

        # Options group
        self.grp_opts = QGroupBox()
        grid = QGridLayout(self.grp_opts)
        self.radio_copy = QRadioButton()
        self.radio_copy.setChecked(True)
        grid.addWidget(self.radio_copy, 0, 0, 1, 2)

        self.radio_gpkg = QRadioButton()
        grid.addWidget(self.radio_gpkg, 1, 0, 1, 2)

        self.chk_vacuum = QCheckBox()
        grid.addWidget(self.chk_vacuum, 2, 1)
        layout.addWidget(self.grp_opts)

        layout.addStretch()

        # Run button (right-aligned)
        run_row = QHBoxLayout()
        run_row.addStretch()
        self.btn_run = QPushButton()
        self.btn_run.setFont(QFont("Arial", 11, QFont.Weight.Bold))
        self.btn_run.setMinimumHeight(36)
        self.btn_run.setMinimumWidth(160)
        self.btn_run.setStyleSheet(
            "QPushButton{background:#2e7d32;color:white;border-radius:6px;"
            "padding:6px 20px}"
            "QPushButton:hover{background:#388e3c}")
        self.btn_run.clicked.connect(self._run)
        run_row.addWidget(self.btn_run)
        layout.addLayout(run_row)

    # ── Tab 2: Extract SHP ─────────────────────────────────────────────
    def _build_tab_extract(self, tab):
        layout = QVBoxLayout(tab)
        layout.setContentsMargins(8, 8, 8, 8)
        layout.setSpacing(6)

        # Description
        self.ext_desc = QLabel()
        self.ext_desc.setWordWrap(True)
        self.ext_desc.setStyleSheet("color:#333; margin-bottom:4px")
        layout.addWidget(self.ext_desc)

        ex_form = QFormLayout()
        ex_form.setContentsMargins(0, 0, 0, 0)

        # GPKG input
        row_gpkg = QHBoxLayout()
        self.gpkg_edit = QLineEdit()
        self.gpkg_edit.setReadOnly(True)
        self.gpkg_edit.setMinimumWidth(340)
        row_gpkg.addWidget(self.gpkg_edit)
        btn_gpkg = QToolButton()
        btn_gpkg.setIcon(
            QIcon(':/images/themes/default/mActionFileOpen.svg'))
        btn_gpkg.clicked.connect(self._browse_gpkg)
        row_gpkg.addWidget(btn_gpkg)
        self.ext_gpkg_label = QLabel("")
        ex_form.addRow(self.ext_gpkg_label, row_gpkg)

        # SHP output dir
        row_shp = QHBoxLayout()
        self.shp_dir_edit = QLineEdit()
        self.shp_dir_edit.setReadOnly(True)
        self.shp_dir_edit.setMinimumWidth(340)
        row_shp.addWidget(self.shp_dir_edit)
        btn_shp_dir = QToolButton()
        btn_shp_dir.setIcon(
            QIcon(':/images/themes/default/mActionFileOpen.svg'))
        btn_shp_dir.clicked.connect(self._browse_shp_dir)
        row_shp.addWidget(btn_shp_dir)
        self.ext_shp_label = QLabel("")
        ex_form.addRow(self.ext_shp_label, row_shp)

        layout.addLayout(ex_form)

        # Load to QGIS checkbox
        self.chk_load_shp = QCheckBox()
        self.chk_load_shp.setChecked(True)
        layout.addWidget(self.chk_load_shp)

        layout.addStretch()

        # Action button
        btn_extract_row = QHBoxLayout()
        btn_extract_row.addStretch()
        self.btn_extract = QPushButton()
        self.btn_extract.setFont(QFont("Arial", 10, QFont.Weight.Bold))
        self.btn_extract.setMinimumHeight(34)
        self.btn_extract.setStyleSheet(
            "QPushButton{background:#1565C0;color:#fff;"
            "border-radius:5px;padding:5px 18px}"
            "QPushButton:hover{background:#1976D2}")
        self.btn_extract.clicked.connect(self._run_extract_shp)
        btn_extract_row.addWidget(self.btn_extract)
        layout.addLayout(btn_extract_row)

    # ------------------------------------------------------------------
    # Retranslate — called on init and on language change
    # ------------------------------------------------------------------
    def _retranslate(self, _lang=None):
        # Tab labels
        self.tabs.setTabText(0, tr("📦 Package"))
        self.tabs.setTabText(1, tr("📤 Extract SHP"))

        # Tab 1 — Package
        self.pkg_desc.setText(
            tr("Package the current QGIS project into a portable folder.\n"
               "All data, styles, labels, and layouts are copied — opens correctly on any machine."))
        self.pkg_dest_label.setText(tr("Destination folder:"))
        self.grp_opts.setTitle(tr("Options"))
        self.radio_copy.setText(tr("Copy original data (keep format)"))
        self.radio_gpkg.setText(tr("Package into a single GeoPackage file (.gpkg)"))
        self.chk_vacuum.setText(tr("Vacuum SQLite file after copy"))
        self.btn_run.setText(tr("📦  Package"))

        # Tab 2 — Extract SHP
        self.ext_desc.setText(
            tr("Select 1 .gpkg file → export ALL layers as .shp (UTF-8) in one operation."))
        self.ext_gpkg_label.setText(tr("GPKG file:"))
        self.ext_shp_label.setText(tr("SHP folder:"))
        self.gpkg_edit.setPlaceholderText(tr("Select .gpkg file..."))
        self.shp_dir_edit.setPlaceholderText(tr("SHP output folder..."))
        self.chk_load_shp.setText(tr("Load exported SHPs into QGIS"))
        self.btn_extract.setText(tr("📤  Export all → SHP"))

        # Bottom buttons
        self.btn_close.setText(tr("Close"))

    # ------------------------------------------------------------------
    # Browse helpers
    # ------------------------------------------------------------------
    def _browse(self):
        path = QFileDialog.getExistingDirectory(
            self, tr("Select destination folder"), self.dir_edit.text()
        )
        if path:
            self.dir_edit.setText(path)

    def _browse_gpkg(self):
        path, _ = QFileDialog.getOpenFileName(
            self, tr("Select GeoPackage file"), "",
            tr("GeoPackage (*.gpkg);;All files (*.*)")
        )
        if path:
            self.gpkg_edit.setText(path)
            # Auto-fill output dir to same folder if empty
            if not self.shp_dir_edit.text():
                self.shp_dir_edit.setText(
                    os.path.join(os.path.dirname(path),
                                 os.path.splitext(
                                     os.path.basename(path))[0] + "_shp"))

    def _browse_shp_dir(self):
        path = QFileDialog.getExistingDirectory(
            self, tr("Select SHP output folder"),
            self.shp_dir_edit.text()
        )
        if path:
            self.shp_dir_edit.setText(path)

    # ------------------------------------------------------------------
    # GPKG → SHP extractor
    # ------------------------------------------------------------------
    def _run_extract_shp(self):
        """Export every layer in a GPKG to individual SHP files (UTF-8)."""
        gpkg_path = self.gpkg_edit.text().strip()
        out_dir = self.shp_dir_edit.text().strip()

        if not gpkg_path or not os.path.isfile(gpkg_path):
            QMessageBox.warning(self, "LVT4U",
                                tr("No valid GPKG file selected."))
            return
        if not out_dir:
            self._browse_shp_dir()
            out_dir = self.shp_dir_edit.text().strip()
        if not out_dir:
            return

        os.makedirs(out_dir, exist_ok=True)

        from osgeo import ogr
        gdal.UseExceptions()

        ds = ogr.Open(gpkg_path)
        if ds is None:
            QMessageBox.critical(self, "LVT4U",
                                 tr("Cannot open file:\n{path}").format(
                                     path=gpkg_path))
            return

        n_layers = ds.GetLayerCount()
        if n_layers == 0:
            QMessageBox.information(self, "LVT4U",
                                    tr("GPKG file contains no layers."))
            ds = None
            return

        shp_driver = ogr.GetDriverByName("ESRI Shapefile")

        pd = QProgressDialog(
            tr("Exporting layers..."),
            tr("Cancel"),
            0, n_layers, self)
        pd.setWindowTitle(tr("LVT4U — Extract GPKG"))
        pd.setWindowModality(QtCompat.WindowModal)
        pd.setMinimumDuration(0)
        pd.show()

        exported, errors = [], []

        for i in range(n_layers):
            if pd.wasCanceled():
                break

            layer = ds.GetLayer(i)
            name = layer.GetName()
            pd.setLabelText(
                tr("Exporting: {name} ({i}/{n})").format(
                    name=name, i=i + 1, n=n_layers))
            QgsApplication.processEvents()

            out_shp = os.path.join(out_dir, f"{name}.shp")

            # Delete existing SHP set to avoid conflicts
            if os.path.exists(out_shp):
                shp_driver.DeleteDataSource(out_shp)

            try:
                out_ds = shp_driver.CreateDataSource(out_shp)
                if out_ds is None:
                    errors.append(
                        tr("{name}: failed to create SHP").format(name=name))
                    continue
                # CopyLayer with UTF-8 encoding option
                out_lyr = out_ds.CopyLayer(
                    layer, name, ["ENCODING=UTF-8"])
                if out_lyr is None:
                    errors.append(
                        tr("{name}: CopyLayer failed").format(name=name))
                else:
                    exported.append(name)
                out_ds = None          # flush & close
            except Exception as exc:
                errors.append(f"{name}: {exc}")

            pd.setValue(i + 1)

        ds = None
        pd.hide()
        pd.deleteLater()

        # Optionally load SHP into QGIS
        if exported and self.chk_load_shp.isChecked():
            for name in exported:
                shp_path = os.path.join(out_dir, f"{name}.shp")
                lyr = QgsVectorLayer(shp_path, name, "ogr")
                if lyr.isValid():
                    QgsProject.instance().addMapLayer(lyr)

        # Result message
        lines = [
            tr("✅ Exported {ok}/{total} layers:").format(
                ok=len(exported), total=n_layers),
            *[f"  • {n}" for n in exported],
        ]
        if errors:
            lines += [
                "",
                tr("⚠️ {n} layer errors:").format(n=len(errors)),
                *[f"  • {e}" for e in errors],
            ]
        lines += ["", tr("📂 Folder: {folder}").format(folder=out_dir)]

        QMessageBox.information(
            self,
            tr("LVT4U — Extract GPKG Result"),
            "\n".join(lines))

    def refresh_layers(self):
        """No-op for interface compatibility."""

    # -----------------------------------------------------------------
    # Packaging engine
    # -----------------------------------------------------------------
    def _run(self):
        project = QgsProject.instance()

        # Must have a saved project
        if not project.fileName():
            QMessageBox.warning(
                self, "LVT4U",
                tr("Project not saved.\nPlease save the project (Ctrl+S) before packaging."))
            return

        if project.isDirty():
            QMessageBox.warning(
                self, "LVT4U",
                tr("Project has unsaved changes.\nPlease save the project (Ctrl+S) before packaging."))
            return

        if not self.dir_edit.text():
            self._browse()
        if not self.dir_edit.text():
            return

        # Build source map: layer → (path, layerName, options)
        src_map = {}
        for lyr in project.mapLayers().values():
            info = _get_source_info(lyr)
            if info:
                src_map[lyr] = info

        if not src_map:
            QMessageBox.information(
                self, "LVT4U",
                tr("No file layers found in the current project.")
            )
            return

        # Determine output directory
        outdir = os.path.join(self.dir_edit.text(), project.baseName())
        home = project.homePath()
        if home and _is_in_dir(home, outdir):
            QMessageBox.warning(
                self, "LVT4U",
                tr("Destination folder must not be inside the project folder!")
            )
            return

        # Handle existing folder
        if os.path.exists(outdir):
            res = QMessageBox.question(
                self, "LVT4U",
                tr("Folder '{outdir}' already exists.\nExisting data will be deleted. Continue?").format(
                    outdir=outdir),
                MsgButton.Yes | MsgButton.No,
            )
            if res != MsgButton.Yes:
                return
            try:
                shutil.rmtree(outdir)
            except Exception as e:
                QMessageBox.critical(
                    self, "LVT4U",
                    tr("Error deleting folder: {e}").format(e=e))
                return

        # Collect symbol/image paths
        context = QgsRenderContext.fromMapSettings(
            self.iface.mapCanvas().mapSettings()
        )
        sym_map = _collect_symbol_paths(project, context)

        if self.radio_gpkg.isChecked():
            # GPKG Packaging mode
            self._run_gpkg_package(project, outdir)
            return

        # Build unique path set (data + symbols)
        all_paths = [path for path, _, _ in src_map.values()]
        all_paths.extend(sym_map.values())
        paths_set = list(set(all_paths))

        # Create directory mapping: avoid name collisions
        dirs_set = list(set(os.path.dirname(p) for p in paths_set))
        names = [project.baseName() + '.qgz']  # reserve project name
        for d in dirs_set:
            name = os.path.basename(d)
            if name in names:
                suffix = 1
                while f"{name}_{suffix}" in names:
                    suffix += 1
                name = f"{name}_{suffix}"
            names.append(name)
        del names[0]  # remove reserved project name
        dir_map = dict(zip(dirs_set, names))
        path_map = {p: dir_map[os.path.dirname(p)] for p in paths_set}

        # Progress dialog
        pd = QProgressDialog(self.iface.mainWindow())
        pd.setWindowTitle(tr("LVT4U — Packaging"))
        pd.setAutoReset(False)
        pd.setMinimumDuration(0)
        pd.setWindowModality(QtCompat.WindowModal)
        pd.setMinimumWidth(400)
        pd.setMaximum(len(path_map) + 1)
        pd.setValue(0)

        orig_project = project.fileName()

        try:
            # ---- Copy files ----
            for path in path_map:
                if pd.wasCanceled():
                    return

                dstdir = os.path.join(outdir, path_map[path])
                os.makedirs(dstdir, exist_ok=True)

                # Use GDAL to get full file list (handles .shp siblings etc.)
                if os.path.isdir(path):
                    file_list = [path]
                else:
                    try:
                        ds = gdal.OpenEx(path)
                        file_list = ds.GetFileList() if ds else [path]
                    except (AttributeError, RuntimeError):
                        file_list = [path]
                    finally:
                        ds = None

                for fpath in file_list:
                    pd.setLabelText(f"Copying: {os.path.basename(fpath)}")
                    QgsApplication.processEvents()
                    try:
                        if os.path.isdir(fpath):
                            shutil.copytree(
                                fpath,
                                os.path.join(dstdir, os.path.basename(fpath))
                            )
                        else:
                            shutil.copy2(fpath, dstdir)
                    except FileNotFoundError:
                        pass  # nosec B110
                    except (PermissionError, IsADirectoryError):
                        try:
                            shutil.copytree(
                                fpath,
                                os.path.join(dstdir, os.path.basename(fpath))
                            )
                        except Exception:
                            pass  # nosec B110

                    # Vacuum SQLite if requested
                    if self.chk_vacuum.isChecked():
                        self._try_vacuum(
                            os.path.join(dstdir, os.path.basename(fpath)), pd
                        )

                pd.setValue(pd.value() + 1)

            if pd.wasCanceled():
                return

            # ---- Remap layer data sources ----
            reg = QgsProviderRegistry.instance()
            for lyr, src in src_map.items():
                path, _, _ = src
                dp = lyr.dataProvider()
                encoding = (dp.encoding()
                            if isinstance(dp, QgsVectorDataProvider) else None)
                parts = reg.decodeUri(dp.name(), lyr.source())
                parts['path'] = QDir(
                    os.path.join(
                        outdir, path_map[path], os.path.basename(path))
                ).absolutePath()
                new_source = reg.encodeUri(dp.name(), parts)
                lyr.setDataSource(
                    new_source, lyr.name(), lyr.providerType(),
                    QgsDataProvider.ProviderOptions()
                )
                if encoding is not None:
                    lyr.dataProvider().setEncoding(encoding)

            # ---- Remap symbol/image paths ----
            for slyr, path in sym_map.items():
                new_path = QDir(
                    os.path.join(
                        outdir, path_map[path], os.path.basename(path))
                ).absolutePath()
                if isinstance(slyr, QgsLayoutItemPicture):
                    fmt = (QgsLayoutItemPicture.Format.FormatSVG
                           if new_path.endswith(('.svg', '.svgz'))
                           else QgsLayoutItemPicture.Format.FormatRaster)
                    slyr.setPicturePath(new_path, fmt)
                else:
                    _set_path(slyr, new_path)

            # ---- Write project ----
            pd.setValue(pd.maximum())
            pd.setLabelText(tr("Saving project..."))
            QgsApplication.processEvents()

            project.setPresetHomePath('')
            project.writeEntryBool('Paths', '/Absolute', False)
            qgz_path = QDir(
                os.path.join(outdir, project.baseName())
            ).absolutePath() + '.qgz'
            result = project.write(qgz_path)

        except Exception as e:
            QMessageBox.critical(
                self, "LVT4U",
                tr("Packaging error: {e}").format(e=e))
            return
        finally:
            pd.hide()
            pd.deleteLater()
            # Reload original project to restore live state
            mb = self.iface.messageBar()
            mb.widgetAdded.connect(mb.popWidget)
            project.read(orig_project)
            mb.widgetAdded.disconnect(mb.popWidget)

        if result:
            self.iface.messageBar().pushSuccess(
                "LVT4U",
                tr("Packaged successfully! → {url}").format(
                    url=f'<a href="{QUrl.fromLocalFile(outdir).toString()}">'
                        f'{outdir}</a>')
            )
            self.close()
        else:
            QMessageBox.warning(
                self, "LVT4U", tr("Failed to save project!"))

    def _run_gpkg_package(self, project, outdir):
        """Package project — complete coverage:

        Category        Destination
        ──────────────  ─────────────────────────────
        Spatial vectors → {name}.gpkg   (native:package)
        Table vectors   → data/         (copy + remap)
        Rasters (TIFF…) → raster/       (copy + sidecar)
        Mesh/PointCloud → data/         (copy + remap)
        SVG/PNG icons   → assets/       (copy + remap)
        WMS/XYZ/WFS     → keep URL      (no copy)
        """
        import processing
        from qgis.core import (
            QgsVectorLayer, QgsRasterLayer,
            QgsMessageLog, Qgis, QgsProviderRegistry,
            QgsDataProvider)

        os.makedirs(outdir, exist_ok=True)
        gpkg_path = os.path.join(outdir, f"{project.baseName()}.gpkg")
        qgz_path = os.path.join(outdir, f"{project.baseName()}.qgz")
        raster_dir = os.path.join(outdir, "raster")
        data_dir = os.path.join(outdir, "data")    # tables / mesh / pc
        assets_dir = os.path.join(outdir, "assets")  # SVG / PNG icons
        orig_project = project.fileName()

        # ── Collect symbol/icon paths BEFORE any changes ──────────────
        context = QgsRenderContext.fromMapSettings(
            self.iface.mapCanvas().mapSettings())
        sym_map = _collect_symbol_paths(project, context)  # {item → path}

        # ── Classify layers ───────────────────────────────────────────
        _ONLINE_PROVIDERS = {
            'wms', 'wfs', 'arcgismapserver', 'arcgisfeatureserver',
            'vectortile', 'xyztiles', 'wcs', 'sensorthings',
        }
        # GeoJSON IS spatial — keep in geo_vectors; handled by native:package
        _TABLE_EXTS_NONSPATIAL = {'.csv', '.xlsx', '.xls', '.ods', '.tsv', '.txt'}

        geo_vectors = []   # spatial vector layers → GPKG
        table_vectors = []   # non-spatial / Excel / CSV → data/
        rasters = []   # file rasters → raster/
        other_files = []   # mesh, point cloud → data/
        online = []   # WMS / XYZ / WFS → keep URL

        for lyr in project.mapLayers().values():
            if not lyr.isValid():
                continue
            prov = (lyr.providerType() or '').lower()
            if prov in _ONLINE_PROVIDERS:
                online.append(lyr)
                continue

            if isinstance(lyr, QgsVectorLayer):
                info = _get_source_info(lyr)
                if info:
                    ext = os.path.splitext(info[0])[1].lower()
                    if ext in _TABLE_EXTS_NONSPATIAL or not lyr.isSpatial():
                        table_vectors.append(lyr)
                    else:
                        geo_vectors.append(lyr)
                else:
                    # virtual / memory layer — try native:package
                    geo_vectors.append(lyr)

            elif isinstance(lyr, QgsRasterLayer):
                info = _get_source_info(lyr)
                if info and os.path.isfile(info[0]):
                    rasters.append(lyr)
                else:
                    online.append(lyr)   # cloud / virtual raster

            else:
                # Mesh, PointCloud, AnnotationLayer, etc.
                info = _get_source_info(lyr)
                if info and os.path.isfile(info[0]):
                    other_files.append(lyr)
                # else: embedded in project — no file to copy

        QgsMessageLog.logMessage(
            f"[Package] geo={len(geo_vectors)} table={len(table_vectors)} "
            f"raster={len(rasters)} other={len(other_files)} "
            f"online={len(online)} icons={len(set(sym_map.values()))}",
            "LVT4U", Qgis.MessageLevel.Info)

        if not any([geo_vectors, table_vectors, rasters, other_files]):
            QMessageBox.warning(
                self, "LVT4U",
                tr("No spatial file layers to package.\n({n} online layers will keep their URL)").format(
                    n=len(online)))
            return

        # ── Progress ──────────────────────────────────────────────────
        total_steps = (1                          # native:package step
                       + len(table_vectors)  # noqa: W503
                       + len(rasters)  # noqa: W503
                       + len(other_files)  # noqa: W503
                       + (1 if sym_map else 0)    # icon copy  # noqa: W503
                       + 1)                       # remap + save  # noqa: W503
        pd = QProgressDialog(self.iface.mainWindow())
        pd.setWindowTitle(tr("LVT4U — Full Package"))
        pd.setAutoReset(False)
        pd.setMinimumDuration(0)
        pd.setWindowModality(QtCompat.WindowModal)
        pd.setMinimumWidth(440)
        pd.setMaximum(total_steps)
        pd.setValue(0)
        QgsApplication.processEvents()

        try:
            step = 0

            # ── Step 1: Geo vectors → GPKG via native:package ──────────
            if geo_vectors:
                pd.setLabelText(
                    tr("Packaging {n} vectors → GPKG...").format(
                        n=len(geo_vectors)))
                QgsApplication.processEvents()
                params = {
                    'LAYERS': geo_vectors,
                    'OUTPUT': gpkg_path,
                    'OVERWRITE': True,
                    'SAVE_STYLES': True,
                    'SAVE_METADATA': True,
                    'SELECTED_FEATURES_ONLY': False,
                }
                processing.run("native:package", params)
            step += 1
            pd.setValue(step)

            # ── Step 2: Table vectors (Excel/CSV) → data/ ──────────────
            os.makedirs(data_dir, exist_ok=True)
            table_path_map = {}   # layer_id → new abs path
            for vlyr in table_vectors:
                if pd.wasCanceled():
                    return
                info = _get_source_info(vlyr)
                if not info:
                    continue
                src_path = info[0]
                pd.setLabelText(
                    tr("Copy table: {name}").format(
                        name=os.path.basename(src_path)))
                QgsApplication.processEvents()
                dst = _safe_dst(data_dir, src_path)
                try:
                    shutil.copy2(src_path, dst)
                    table_path_map[vlyr.id()] = dst
                except Exception as exc:
                    QgsMessageLog.logMessage(
                        f"[Package] Table copy failed: {src_path}: {exc}",
                        "LVT4U", Qgis.MessageLevel.Warning)
                step += 1
                pd.setValue(step)

            # ── Step 3: Rasters → raster/ (with all sidecar files) ─────
            if rasters:
                os.makedirs(raster_dir, exist_ok=True)
            raster_path_map = {}  # layer_id → new abs path (main file)
            for rlyr in rasters:
                if pd.wasCanceled():
                    return
                info = _get_source_info(rlyr)
                if not info:
                    continue
                src_path = info[0]
                pd.setLabelText(
                    tr("Copy raster: {name}").format(
                        name=os.path.basename(src_path)))
                QgsApplication.processEvents()

                # GDAL gives full sidecar list (.aux.xml, .tfw, .ovr, …)
                try:
                    ds = gdal.OpenEx(src_path)
                    file_list = (ds.GetFileList() or [src_path]) if ds else [src_path]
                    ds = None
                except Exception:
                    file_list = [src_path]

                for fpath in file_list:
                    if os.path.isfile(fpath):
                        dst = _safe_dst(raster_dir, fpath)
                        try:
                            shutil.copy2(fpath, dst)
                        except Exception as exc:
                            QgsMessageLog.logMessage(
                                f"[Package] Raster copy failed: {fpath}: {exc}",
                                "LVT4U", Qgis.MessageLevel.Warning)

                raster_path_map[rlyr.id()] = os.path.join(
                    raster_dir, os.path.basename(src_path))
                step += 1
                pd.setValue(step)

            # ── Step 4: Mesh / PointCloud → data/ ─────────────────────
            other_path_map = {}  # layer_id → new abs path
            for lyr in other_files:
                if pd.wasCanceled():
                    return
                info = _get_source_info(lyr)
                if not info:
                    continue
                src_path = info[0]
                pd.setLabelText(
                    tr("Copy: {name}").format(name=os.path.basename(src_path)))
                QgsApplication.processEvents()
                dst = _safe_dst(data_dir, src_path)
                try:
                    shutil.copy2(src_path, dst)
                    other_path_map[lyr.id()] = dst
                except Exception as exc:
                    QgsMessageLog.logMessage(
                        f"[Package] Copy failed: {src_path}: {exc}",
                        "LVT4U", Qgis.MessageLevel.Warning)
                step += 1
                pd.setValue(step)

            # ── Step 5: SVG/PNG icons → assets/ ───────────────────────
            asset_remap = {}   # old_abs_path → new_abs_path
            if sym_map:
                os.makedirs(assets_dir, exist_ok=True)
                pd.setLabelText(
                    tr("Copy {n} icons → assets/...").format(
                        n=len(set(sym_map.values()))))
                QgsApplication.processEvents()
                for src_path in set(sym_map.values()):
                    dst = _safe_dst(assets_dir, src_path)
                    try:
                        shutil.copy2(src_path, dst)
                        asset_remap[src_path] = dst
                    except Exception as exc:
                        QgsMessageLog.logMessage(
                            f"[Package] Icon copy failed: {src_path}: {exc}",
                            "LVT4U", Qgis.MessageLevel.Warning)
                step += 1
                pd.setValue(step)

            # ── Step 6: Remap sources + save project ───────────────────
            pd.setLabelText(tr("Saving project..."))
            QgsApplication.processEvents()

            reg = QgsProviderRegistry.instance()

            # Geo vectors → new GPKG path
            for vlyr in geo_vectors:
                dp = vlyr.dataProvider()
                if not dp:
                    continue
                parts = reg.decodeUri(dp.name(), vlyr.source())
                parts['path'] = QDir(gpkg_path).absolutePath()
                # layerName preserved by native:package → keep as-is
                new_src = reg.encodeUri(dp.name(), parts)
                enc = (dp.encoding()
                       if isinstance(dp, QgsVectorDataProvider) else None)
                vlyr.setDataSource(
                    new_src, vlyr.name(), vlyr.providerType(),
                    QgsDataProvider.ProviderOptions())
                if enc:
                    try:
                        vlyr.dataProvider().setEncoding(enc)
                    except Exception:
                        pass  # nosec B110

            # Table vectors → data/
            for vlyr in table_vectors:
                new_path = table_path_map.get(vlyr.id())
                if not new_path:
                    continue
                dp = vlyr.dataProvider()
                if not dp:
                    continue
                parts = reg.decodeUri(dp.name(), vlyr.source())
                parts['path'] = new_path
                new_src = reg.encodeUri(dp.name(), parts)
                enc = (dp.encoding()
                       if isinstance(dp, QgsVectorDataProvider) else None)
                vlyr.setDataSource(
                    new_src, vlyr.name(), vlyr.providerType(),
                    QgsDataProvider.ProviderOptions())
                if enc:
                    try:
                        vlyr.dataProvider().setEncoding(enc)
                    except Exception:
                        pass  # nosec B110

            # Rasters → raster/
            for rlyr in rasters:
                new_path = raster_path_map.get(rlyr.id())
                if not new_path:
                    continue
                dp = rlyr.dataProvider()
                if not dp:
                    continue
                rlyr.setDataSource(
                    new_path, rlyr.name(), rlyr.providerType(),
                    QgsDataProvider.ProviderOptions())

            # Other file layers → data/
            for lyr in other_files:
                new_path = other_path_map.get(lyr.id())
                if not new_path:
                    continue
                dp = lyr.dataProvider()
                if not dp:
                    continue
                lyr.setDataSource(
                    new_path, lyr.name(), lyr.providerType(),
                    QgsDataProvider.ProviderOptions())

            # Remap SVG/PNG icons in symbols + layouts
            for sym_item, src_path in sym_map.items():
                new_path = asset_remap.get(src_path, src_path)
                try:
                    if isinstance(sym_item, QgsLayoutItemPicture):
                        fmt = (QgsLayoutItemPicture.Format.FormatSVG
                               if new_path.endswith(('.svg', '.svgz'))
                               else QgsLayoutItemPicture.Format.FormatRaster)
                        sym_item.setPicturePath(new_path, fmt)
                    else:
                        _set_path(sym_item, new_path)
                except Exception:
                    pass  # nosec B110

            # Save .qgz
            project.setPresetHomePath('')
            project.writeEntryBool('Paths', '/Absolute', False)
            project.write(qgz_path)

            step += 1
            pd.setValue(pd.maximum())

            # ── Summary ────────────────────────────────────────────────
            parts_msg = []
            if geo_vectors:
                parts_msg.append(f"{len(geo_vectors)} vector → GPKG")
            if table_vectors:
                parts_msg.append(f"{len(table_vectors)} table → data/")
            if rasters:
                parts_msg.append(f"{len(rasters)} raster → raster/")
            if other_files:
                parts_msg.append(f"{len(other_files)} mesh/PC → data/")
            if sym_map:
                n_icons = len(set(sym_map.values()))
                parts_msg.append(f"{n_icons} icon → assets/")
            if online:
                parts_msg.append(f"{len(online)} online → keep URL")

            msg = (f'Packaged successfully! '
                   f'{" | ".join(parts_msg)} → '
                   f'<a href="{QUrl.fromLocalFile(outdir).toString()}">'
                   f'{outdir}</a>')
            self.iface.messageBar().pushSuccess("LVT4U", msg)
            self.close()

        except Exception as e:
            import traceback
            QMessageBox.critical(
                self, "LVT4U",
                tr("GPKG packaging error:\n{e}\n\n{tb}").format(
                    e=e, tb=traceback.format_exc()[-500:]))
        finally:
            pd.hide()
            pd.deleteLater()
            # Reload original project to restore live state
            mb = self.iface.messageBar()
            mb.widgetAdded.connect(mb.popWidget)
            project.read(orig_project)
            mb.widgetAdded.disconnect(mb.popWidget)

    @staticmethod
    def _try_vacuum(filepath, pd):
        """Vacuum a SQLite file if detected."""
        import sqlite3
        from contextlib import closing
        try:
            with open(filepath, 'rb') as f:
                header = f.read(16)
            if header == b'SQLite format 3\x00':
                pd.setLabelText(f"Vacuum: {os.path.basename(filepath)}")
                QgsApplication.processEvents()
                with closing(sqlite3.connect(filepath)) as conn:
                    conn.execute('VACUUM')
        except Exception:  # nosec B110
            pass  # nosec B110

    def _show_help(self):
        """Open help dialog for Map Packager module."""
        from ..shared.help_dialog import HelpDialog
        dlg = HelpDialog("map_packager", parent=self)
        dlg.exec()
