# -*- coding: utf-8 -*-
"""
LVT Font Converter — Convert font encoding for a vector layer and export.

Supports:
  - TCVN3 (ABC) → Unicode
  - VNI → Unicode
  - Unicode → TCVN3 (.VnTime for MapInfo)
  - No conversion (re-export with format/CRS change)
  - Fix Mojibake (CP1252 double-encode → UTF-8)
  - Raw DAT Bypass (Triple Mojibake — bypass GDAL entirely)
  - Export to SHP (with .cpg) or MapInfo TAB

Mapping tables based on u-convert (https://github.com/anhskohbo/u-convert)
and vietunicode.sourceforge.net/charset standard.
Mojibake fix based on Cam Lộ, Quảng Trị case study (07/2026).

Author: Lộc Vũ Trung (LVT)
License: GPL-3.0
"""
from .._crs_list import CRS_LIST
import os
import struct
from qgis.PyQt.QtCore import Qt
from ..shared.qt_compat import QVariant
from qgis.PyQt.QtWidgets import (
    QDialog, QVBoxLayout, QHBoxLayout, QLabel, QComboBox,
    QPushButton, QFormLayout, QMessageBox, QFileDialog,
    QTextEdit, QProgressBar, QApplication,
    QLineEdit, QListWidget, QListWidgetItem, QGroupBox, QCheckBox,
)
from qgis.core import (
    QgsProject, QgsCoordinateReferenceSystem, QgsVectorLayer,
    QgsVectorFileWriter,
)
from ..shared.i18n import tr
from ..shared.qt_compat import QtCompat, MsgButton


# ═══════════════════════════════════════════════════════════════
# TCVN3 ↔ Unicode mapping (134 chars, from u-convert reference)
# TCVN3 uppercase = multi-byte (base-char + accent-byte)
# TCVN3 lowercase = single accent-byte
# ═══════════════════════════════════════════════════════════════

# Index-matched parallel arrays: _UNICODE_FULL[i] ↔ _TCVN3_FULL[i]
_UNICODE_FULL = [
    'À', 'Á', 'Â', 'Ã', 'È', 'É', 'Ê', 'Ì', 'Í', 'Ò',
    'Ó', 'Ô', 'Õ', 'Ù', 'Ú', 'Ý', 'à', 'á', 'â', 'ã',
    'è', 'é', 'ê', 'ì', 'í', 'ò', 'ó', 'ô', 'õ', 'ù',
    'ú', 'ý', 'Ă', 'ă', 'Đ', 'đ', 'Ĩ', 'ĩ', 'Ũ', 'ũ',
    'Ơ', 'ơ', 'Ư', 'ư', 'Ạ', 'ạ', 'Ả', 'ả', 'Ấ', 'ấ',
    'Ầ', 'ầ', 'Ẩ', 'ẩ', 'Ẫ', 'ẫ', 'Ậ', 'ậ', 'Ắ', 'ắ',
    'Ằ', 'ằ', 'Ẳ', 'ẳ', 'Ẵ', 'ẵ', 'Ặ', 'ặ', 'Ẹ', 'ẹ',
    'Ẻ', 'ẻ', 'Ẽ', 'ẽ', 'Ế', 'ế', 'Ề', 'ề', 'Ể', 'ể',
    'Ễ', 'ễ', 'Ệ', 'ệ', 'Ỉ', 'ỉ', 'Ị', 'ị', 'Ọ', 'ọ',
    'Ỏ', 'ỏ', 'Ố', 'ố', 'Ồ', 'ồ', 'Ổ', 'ổ', 'Ỗ', 'ỗ',
    'Ộ', 'ộ', 'Ớ', 'ớ', 'Ờ', 'ờ', 'Ở', 'ở', 'Ỡ', 'ỡ',
    'Ợ', 'ợ', 'Ụ', 'ụ', 'Ủ', 'ủ', 'Ứ', 'ứ', 'Ừ', 'ừ',
    'Ử', 'ử', 'Ữ', 'ữ', 'Ự', 'ự', 'Ỳ', 'ỳ', 'Ỵ', 'ỵ',
    'Ỷ', 'ỷ', 'Ỹ', 'ỹ',
]

_TCVN3_FULL = [
    'A\xb5',
    'A\xb8',
    '\xa2',
    'A\xb7',
    'E\xcc',
    'E\xd0',
    '\xa3',
    'I\xd7',
    'I\xdd',
    'O\xdf',
    'O\xe3',
    '\xa4',
    'O\xe2',
    'U\xef',
    'U\xf3',
    'Y\xfd',
    '\xb5',
    '\xb8',
    '\xa9',
    '\xb7',
    '\xcc',
    '\xd0',
    '\xaa',
    '\xd7',
    '\xdd',
    '\xdf',
    '\xe3',
    '\xab',
    '\xe2',
    '\xef',
    '\xf3',
    '\xfd',
    '\xa1',
    '\xa8',
    '\xa7',
    '\xae',
    'I\xdc',
    '\xdc',
    'U\xf2',
    '\xf2',
    '\xa5',
    '\xac',
    '\xa6',
    '\xad',
    'A\xb9',
    '\xb9',
    'A\xb6',
    '\xb6',
    '\xa2\xca',
    '\xca',
    '\xa2\xc7',
    '\xc7',
    '\xa2\xc8',
    '\xc8',
    '\xa2\xc9',
    '\xc9',
    '\xa2\xcb',
    '\xcb',
    '\xa1\xbe',
    '\xbe',
    '\xa1\xbb',
    '\xbb',
    '\xa1\xbc',
    '\xbc',
    '\xa1\xbd',
    '\xbd',
    '\xa1\xc6',
    '\xc6',
    'E\xd1',
    '\xd1',
    'E\xce',
    '\xce',
    'E\xcf',
    '\xcf',
    '\xa3\xd5',
    '\xd5',
    '\xa3\xd2',
    '\xd2',
    '\xa3\xd3',
    '\xd3',
    '\xa3\xd4',
    '\xd4',
    '\xa3\xd6',
    '\xd6',
    'I\xd8',
    '\xd8',
    'I\xde',
    '\xde',
    'O\xe4',
    '\xe4',
    'O\xe1',
    '\xe1',
    '\xa4\xe8',
    '\xe8',
    '\xa4\xe5',
    '\xe5',
    '\xa4\xe6',
    '\xe6',
    '\xa4\xe7',
    '\xe7',
    '\xa4\xe9',
    '\xe9',
    '\xa5\xed',
    '\xed',
    '\xa5\xea',
    '\xea',
    '\xa5\xeb',
    '\xeb',
    '\xa5\xec',
    '\xec',
    '\xa5\xee',
    '\xee',
    'U\xf4',
    '\xf4',
    'U\xf1',
    '\xf1',
    '\xa6\xf8',
    '\xf8',
    '\xa6\xf5',
    '\xf5',
    '\xa6\xf6',
    '\xf6',
    '\xa6\xf7',
    '\xf7',
    '\xa6\xf9',
    '\xf9',
    'Y\xfa',
    '\xfa',
    'Y\xfe',
    '\xfe',
    'Y\xfb',
    '\xfb',
    'Y\xfc',
    '\xfc',
]

# Build lookup dicts
_UNI2TCVN = {}
_TCVN2UNI_1 = {}   # Single-byte TCVN3 → Unicode
_TCVN2UNI_2 = {}   # Two-byte TCVN3 → Unicode
for _i in range(len(_UNICODE_FULL)):
    _u = _UNICODE_FULL[_i]
    _t = _TCVN3_FULL[_i]
    _UNI2TCVN[_u] = _t
    if len(_t) == 2:
        _TCVN2UNI_2[_t] = _u
    else:
        _TCVN2UNI_1[_t] = _u

# CRS list (shared across modules)


class FontConverterDialog(QDialog):
    """Font Converter: change encoding + export SHP/TAB with CRS."""

    def __init__(self, iface, parent=None):
        super().__init__(parent or iface.mainWindow())
        self.iface = iface
        self.setWindowTitle(f"LVT — {tr('Font Converter')}")
        self.setMinimumSize(580, 460)
        self.setWindowFlags(
            self.windowFlags() & ~QtCompat.WindowContextHelpButtonHint)
        self._build_ui()

    def _build_ui(self):
        ly = QVBoxLayout(self)

        # Header
        hdr = QLabel(f"<h3>\U0001f524 {tr('Font Converter')}</h3>")
        ly.addWidget(hdr)
        ly.addWidget(
            QLabel(
                tr('Convert font TCVN3/VNI \u2194 Unicode, export Shapefile or MapInfo TAB.')))

        form = QFormLayout()

        # --- Layer list (multi-select) ---
        grp_layer = QGroupBox(tr('Layers'))
        vlay = QVBoxLayout(grp_layer)
        btn_sel_row = QHBoxLayout()
        btn_all = QPushButton(tr('Select All'))
        btn_none = QPushButton(tr('Deselect All'))
        btn_all.clicked.connect(lambda: self._toggle_all_layers(True))
        btn_none.clicked.connect(lambda: self._toggle_all_layers(False))
        btn_sel_row.addWidget(btn_all)
        btn_sel_row.addWidget(btn_none)
        btn_sel_row.addStretch()
        vlay.addLayout(btn_sel_row)

        self.lst_layers = QListWidget()
        self.lst_layers.setMinimumHeight(100)
        vlay.addWidget(self.lst_layers)
        ly.addWidget(grp_layer)

        # Conversion mode
        self.cmb_from = QComboBox()
        self.cmb_from.addItems([
            "TCVN3 (ABC) \u2192 Unicode",
            "VNI \u2192 Unicode",
            "Unicode \u2192 TCVN3 (.VnTime)",
            tr('No conversion'),
            "Fix Mojibake (MapInfo \u2192 UTF-8)",
            "Raw DAT Bypass (Triple Mojibake)",
        ])
        self.cmb_from.currentIndexChanged.connect(self._update_suffix)
        form.addRow(tr('Conversion:'), self.cmb_from)

        # Output format
        self.cmb_format = QComboBox()
        self.cmb_format.addItems([
            "\U0001f4c1 Shapefile (*.shp)",
            "\U0001f4c1 MapInfo TAB (*.tab)",
        ])
        form.addRow(tr('Format:'), self.cmb_format)

        # Target CRS
        self.cmb_crs = QComboBox()
        for label, _ in CRS_LIST:
            self.cmb_crs.addItem(label)
        for i, (_, code) in enumerate(CRS_LIST):
            if not code:
                self.cmb_crs.model().item(i).setEnabled(False)
        form.addRow(tr('Export CRS:'), self.cmb_crs)

        ly.addLayout(form)

        # --- Output folder section ---
        grp_out = QGroupBox(tr('Output'))
        vout = QVBoxLayout(grp_out)

        self.chk_folder = QCheckBox(tr('Export to folder (auto filename)'))
        self.chk_folder.setChecked(True)
        self.chk_folder.stateChanged.connect(self._toggle_folder_mode)
        vout.addWidget(self.chk_folder)

        row_path = QHBoxLayout()
        self.txt_folder = QLineEdit()
        self.txt_folder.setPlaceholderText(tr('Select output folder...'))
        row_path.addWidget(self.txt_folder)
        btn_browse = QPushButton('\U0001f4c2')
        btn_browse.setFixedWidth(36)
        btn_browse.clicked.connect(self._pick_folder)
        row_path.addWidget(btn_browse)
        vout.addLayout(row_path)

        row_suffix = QHBoxLayout()
        row_suffix.addWidget(QLabel(tr('Suffix:')))
        self.txt_suffix = QLineEdit('_UTF8')
        self.txt_suffix.setMaximumWidth(160)
        row_suffix.addWidget(self.txt_suffix)
        row_suffix.addStretch()
        vout.addLayout(row_suffix)

        ly.addWidget(grp_out)

        # Progress
        self.progress = QProgressBar()
        self.progress.setVisible(False)
        ly.addWidget(self.progress)

        # Log
        self.log = QTextEdit()
        self.log.setReadOnly(True)
        self.log.setMaximumHeight(160)
        self.log.setVisible(False)
        ly.addWidget(self.log)

        # Buttons
        btn_row = QHBoxLayout()
        self.btn_convert = QPushButton(f"\U0001f504 {tr('Convert & Export')}")
        self.btn_convert.setStyleSheet(
            "QPushButton{background:#2e7d32;color:#fff;font-weight:bold;"
            "padding:8px 20px;border-radius:4px}"
            "QPushButton:hover{background:#388e3c}"
        )
        self.btn_convert.clicked.connect(self._do_convert)
        btn_row.addWidget(self.btn_convert)

        btn_help = QPushButton(f"\u2753 {tr('Help')}")
        btn_help.setStyleSheet(
            "QPushButton{background:#ff8f00;color:#fff;font-weight:bold;"
            "padding:8px 14px;border-radius:4px}"
            "QPushButton:hover{background:#ffa000}"
        )
        btn_help.clicked.connect(self._show_help)
        btn_row.addWidget(btn_help)

        btn_close = QPushButton(tr('Close'))
        btn_close.clicked.connect(self.close)
        btn_row.addWidget(btn_close)
        ly.addLayout(btn_row)
        ly.addStretch()

    def refresh_layers(self):
        """Populate layer list from current QGIS project."""
        self.lst_layers.clear()
        for layer in QgsProject.instance().mapLayers().values():
            if isinstance(layer, QgsVectorLayer):
                item = QListWidgetItem(layer.name())
                item.setFlags(item.flags() | QtCompat.ItemIsUserCheckable)
                item.setCheckState(QtCompat.Unchecked)
                item.setData(QtCompat.UserRole, layer.id())
                self.lst_layers.addItem(item)

    # --- Helper methods for batch / output ---

    def _toggle_all_layers(self, checked):
        """Check or uncheck all layers in the list."""
        state = QtCompat.Checked if checked else QtCompat.Unchecked
        for i in range(self.lst_layers.count()):
            self.lst_layers.item(i).setCheckState(state)

    def _toggle_folder_mode(self, state):
        """Enable / disable folder-mode widgets."""
        enabled = bool(state)
        self.txt_folder.setEnabled(enabled)
        self.txt_suffix.setEnabled(enabled)

    def _pick_folder(self):
        """Browse for output folder."""
        folder = QFileDialog.getExistingDirectory(
            self, tr('Select output folder'))
        if folder:
            self.txt_folder.setText(folder)

    def _update_suffix(self, idx):
        """Auto-fill suffix based on selected conversion mode."""
        suffixes = ['_UTF8', '_UTF8', '_TCVN3', '', '_fixed', '_fixed_raw']
        self.txt_suffix.setText(suffixes[idx] if idx < len(suffixes) else '')

    # ═══════════════════════════════════════════════════════════════
    # Main export logic
    # ═══════════════════════════════════════════════════════════════
    def _do_convert(self):
        # Collect checked layers
        project = QgsProject.instance()
        layers = []
        for i in range(self.lst_layers.count()):
            item = self.lst_layers.item(i)
            if item.checkState() == QtCompat.Checked:
                layer_id = item.data(QtCompat.UserRole)
                layer = project.mapLayer(layer_id)
                if layer:
                    layers.append(layer)

        if not layers:
            QMessageBox.warning(self, "LVT", tr('No layer selected.'))
            return

        # --- CRS ---
        idx = self.cmb_crs.currentIndex()
        crs_code = CRS_LIST[idx][1]

        # --- Mode: 0=TCVN3→Uni, 1=VNI→Uni, 2=Uni→TCVN3, 3=none ---
        mode = self.cmb_from.currentIndex()

        # --- Format: 0=SHP, 1=TAB ---
        fmt_idx = self.cmb_format.currentIndex()
        if fmt_idx == 1:
            filt = "MapInfo TAB (*.tab)"
            driver = "MapInfo File"
            ext = ".tab"
        else:
            filt = "Shapefile (*.shp)"
            driver = "ESRI Shapefile"
            ext = ".shp"

        # --- Folder mode (batch) ---
        if self.chk_folder.isChecked():
            folder = self.txt_folder.text().strip()
            if not folder or not os.path.isdir(folder):
                QMessageBox.warning(
                    self, "LVT", tr('Please select a valid output folder.'))
                return
            suffix = self.txt_suffix.text().strip()
        else:
            # Single-file mode (legacy): only first layer
            if len(layers) > 1:
                QMessageBox.warning(
                    self, "LVT",
                    tr('Single-file mode only supports one layer. '
                       'Please check only one layer or use folder mode.'))
                return
            path, _ = QFileDialog.getSaveFileName(self, "Save File", "", filt)
            if not path:
                return
            if not path.lower().endswith(ext):
                path += ext

        self.progress.setVisible(True)
        self.log.setVisible(True)
        self.log.clear()
        self.btn_convert.setEnabled(False)
        QApplication.processEvents()

        success = 0
        errors = 0
        for layer in layers:
            # Resolve CRS per layer
            if not crs_code:
                lyr_crs_code = layer.crs().authid()
            else:
                lyr_crs_code = crs_code
            target_crs = QgsCoordinateReferenceSystem(lyr_crs_code)
            if not target_crs.isValid():
                target_crs = layer.crs()

            # Build output path
            if self.chk_folder.isChecked():
                safe_name = layer.name().replace(' ', '_')
                path = os.path.join(folder, f"{safe_name}{suffix}{ext}")

            self.log.append(f"\n{'─' * 50}")
            try:
                self._export(layer, path, driver, ext, mode, target_crs)
                success += 1
            except Exception as e:
                errors += 1
                self.log.append(f"❌ [{layer.name()}] Exception: {e}")
                import traceback
                self.log.append(traceback.format_exc())

        self.btn_convert.setEnabled(True)
        self.progress.setVisible(False)

        if len(layers) > 1:
            self.log.append(
                f"\n{'═' * 50}\n"
                f"📊 Batch result: {success} OK, {errors} errors "
                f"(total {len(layers)} layers)")

    def _export(self, layer, path, driver, ext, mode, target_crs):
        """Convert text fields in memory, then write using QgsVectorFileWriter."""
        # Identify string fields
        fields_list = []
        for field in layer.fields():
            if field.type() == QVariant.String:
                fields_list.append(field.name())

        features = list(layer.getFeatures())
        total = len(features)
        self.progress.setMaximum(total + 5)

        mode_labels = [
            "TCVN3 → Unicode", "VNI → Unicode",
            "Unicode → TCVN3", "No conversion",
            "Fix Mojibake", "Raw DAT Bypass",
        ]
        self.log.append(f"📋 Layer: {layer.name()} — {total} features")
        self.log.append(
            f"🔤 Text fields: {len(fields_list)} ({', '.join(fields_list[:5])})")
        self.log.append(f"🔄 Mode: {mode_labels[mode]}")
        self.log.append(f"💾 Format: {driver}")
        QApplication.processEvents()

        # --- Choose encoding ---
        # For Unicode→TCVN3 + MapInfo TAB: use ISO-8859-1 directly
        # so bytes like 0xAD (ư) and 0xA6 (Ư) are written as-is
        # without UTF-8 multi-byte encoding that causes issues.
        if mode == 2 and driver == "MapInfo File":
            write_enc = "ISO-8859-1"
            self.log.append("📝 Encoding: ISO-8859-1 (direct TCVN3 bytes)")
        else:
            write_enc = "UTF-8"

        writer = QgsVectorFileWriter(
            path, write_enc,
            layer.fields(), layer.wkbType(), target_crs,
            driver
        )

        if writer.hasError() != QgsVectorFileWriter.WriterError.NoError:
            self.log.append(f"❌ Writer error: {writer.errorMessage()}")
            QMessageBox.critical(self, "Error", writer.errorMessage())
            del writer
            return

        converted_count = 0
        for i, feat in enumerate(features):
            self.progress.setValue(i + 1)

            # Convert text fields
            if mode not in (3, 5):  # Skip for 'no conversion' and 'raw DAT'
                for fn in fields_list:
                    old_val = feat[fn]
                    if old_val is not None and isinstance(
                            old_val, str) and old_val:
                        if mode == 0:
                            new_val = self._convert_tcvn3_to_unicode(old_val)
                        elif mode == 1:
                            new_val = self._convert_vni_to_unicode(old_val)
                        elif mode == 2:
                            new_val = self._convert_unicode_to_tcvn3(old_val)
                        elif mode == 4:
                            new_val = self._fix_mojibake(old_val)
                        else:
                            new_val = old_val
                        if new_val != old_val:
                            converted_count += 1
                            feat[fn] = new_val

            writer.addFeature(feat)

        del writer  # Close and flush

        self.log.append(f"🔤 Converted: {converted_count} values")
        self.progress.setValue(total + 2)
        QApplication.processEvents()

        # --- Mode 5: Raw DAT Bypass — overwrite output with raw-decoded text ---
        if mode == 5:
            try:
                self._fix_mojibake_raw(layer, path, driver, ext, target_crs)
                self.log.append("🔧 Raw DAT bypass: patched text from binary")
            except Exception as e:
                self.log.append(f"⚠️ Raw DAT bypass failed: {e}")
                self.log.append("   Falling back to normal export")
                import traceback
                self.log.append(traceback.format_exc())

        # --- Post-process for MapInfo TAB: patch charset + re-encode .DAT ---
        # Skip when using ISO-8859-1 (mode 2) — bytes are already correct
        if driver == "MapInfo File" and mode not in (2, 5):
            self._postprocess_tab(path)
        elif driver == "MapInfo File" and mode == 2:
            # Just patch the TAB header charset for TCVN3
            self._patch_tab_charset(path)

        # --- Write .cpg file for SHP ---
        if driver == "ESRI Shapefile":
            cpg_path = os.path.splitext(path)[0] + ".cpg"
            try:
                with open(cpg_path, 'w') as f:
                    f.write("UTF-8")
                self.log.append(f"📄 Created {os.path.basename(cpg_path)} (UTF-8)")
            except Exception as e:
                self.log.append(f"⚠️ Could not write .cpg: {e}")

        # --- Load result & set encoding ---
        load_enc = 'System' if driver == 'MapInfo File' else 'UTF-8'
        try:
            result_layer = QgsVectorLayer(
                path, os.path.basename(path).replace(
                    ext, ''), 'ogr')
            result_layer.setProviderEncoding('System')
            result_layer.dataProvider().setEncoding(load_enc)
            if result_layer.isValid():
                QgsProject.instance().addMapLayer(result_layer)
                self.log.append(f"✅ Added to project: {result_layer.name()}")
        except Exception as e:
            self.log.append(f"⚠️ Could not add layer: {e}")

        self.progress.setValue(total + 5)

        crs_str = target_crs.authid()
        self.log.append(
            f"\n✅ HOÀN THÀNH!\n"
            f"   File: {os.path.basename(path)}\n"
            f"   CRS: {crs_str}\n"
            f"   Features: {total}\n"
            f"   Font conversions: {converted_count}\n"
            f"   Encoding: {load_enc}"
        )

        tip = ""
        if mode == 2:
            tip = (
                "\n💡 Mở trong MapInfo:\n"
                "   Font: .VnTime / .VnArial\n"
                "   Mở trực tiếp — encoding WindowsLatin1"
            )

        QMessageBox.information(
            self, "✅ Done / Hoàn thành",
            (f"Exported {total} features → {os.path.basename(path)}\n"
             f"Format: {driver}\n"
             f"CRS: {crs_str}\n"
             f"Font conversions: {converted_count}") + tip
        )

    def _patch_tab_charset(self, tab_path):
        """Patch .TAB header charset only (no .DAT re-encoding needed)."""
        try:
            with open(tab_path, 'r', encoding='ascii', errors='replace') as f:
                header = f.read()
            patched = header.replace(
                'Charset "Neutral"', 'Charset "WindowsLatin1"'
            ).replace(
                '!charset Neutral', '!charset WindowsLatin1'
            )
            if patched != header:
                with open(tab_path, 'w', encoding='ascii', errors='replace') as f:
                    f.write(patched)
                self.log.append('📝 .TAB charset → WindowsLatin1')
        except Exception as e:
            self.log.append(f'⚠️ Could not patch .TAB header: {e}')

    def _postprocess_tab(self, tab_path):
        """Post-process TAB files for MapInfo compatibility.

        QgsVectorFileWriter always writes Charset "Neutral" regardless of
        the encoding parameter. This method:
        1. Patches .TAB header: Neutral → WindowsLatin1
        2. Re-encodes .DAT text fields: UTF-8 multi-byte → single-byte
           using dBASE record structure (preserves fixed-width fields).
        """
        import struct

        # Step 1: Patch .TAB header charset
        try:
            with open(tab_path, 'r', encoding='ascii', errors='replace') as f:
                header = f.read()
            patched = header.replace(
                'Charset "Neutral"', 'Charset "WindowsLatin1"'
            ).replace(
                '!charset Neutral', '!charset WindowsLatin1'
            )
            if patched != header:
                with open(tab_path, 'w', encoding='ascii', errors='replace') as f:
                    f.write(patched)
                self.log.append('📝 .TAB charset: Neutral → WindowsLatin1')
        except Exception as e:
            self.log.append(f'⚠️ Could not patch .TAB header: {e}')

        # Step 2: Re-encode .DAT file (dBASE III format)
        dat_path = os.path.splitext(tab_path)[0] + '.dat'
        if not os.path.exists(dat_path):
            return

        try:
            with open(dat_path, 'rb') as f:
                data = bytearray(f.read())

            if len(data) < 32:
                return

            # Parse dBASE header
            num_records = struct.unpack_from('<I', data, 4)[0]
            header_size = struct.unpack_from('<H', data, 8)[0]
            record_size = struct.unpack_from('<H', data, 10)[0]

            # Parse field descriptors (32 bytes each, starting at byte 32)
            fields = []
            offset = 32
            while offset < header_size - 1 and data[offset] != 0x0D:
                raw_name = data[offset:offset + 11]
                fname = raw_name.split(b'\x00')[0].decode('ascii', errors='replace')
                ftype = chr(data[offset + 11])
                flen = data[offset + 16]
                fields.append((fname, ftype, flen))
                offset += 32

            # Process each record
            n_fixed = 0
            for rec_idx in range(num_records):
                rec_start = header_size + rec_idx * record_size
                field_offset = 1  # Skip deletion flag byte

                for fname, ftype, flen in fields:
                    fstart = rec_start + field_offset
                    fend = fstart + flen

                    if ftype == 'C' and fend <= len(data):
                        # Character field: decode UTF-8 → re-encode latin-1
                        raw = bytes(data[fstart:fend])
                        try:
                            text = raw.decode('utf-8')
                            # Encode as latin-1 (covers U+0000-U+00FF)
                            latin = text.encode('latin-1', errors='replace')
                            # Pad to original field width with spaces
                            padded = latin[:flen].ljust(flen, b' ')
                            if padded != raw:
                                data[fstart:fend] = padded
                                n_fixed += 1
                        except (UnicodeDecodeError, UnicodeEncodeError):
                            pass  # Leave field as-is if conversion fails

                    field_offset += flen

            if n_fixed > 0:
                with open(dat_path, 'wb') as f:
                    f.write(bytes(data))
                self.log.append(
                    f'🔧 Re-encoded {n_fixed} fields (UTF-8 → latin-1) in .DAT')
            else:
                self.log.append('📝 .DAT: no re-encoding needed')

        except Exception as e:
            self.log.append(f'⚠️ Could not re-encode .DAT: {e}')

    # ═══════════════════════════════════════════════════════════════
    # Conversion engines (u-convert reference, multi-byte aware)
    # ═══════════════════════════════════════════════════════════════
    @staticmethod
    def _convert_tcvn3_to_unicode(text):
        """TCVN3 → Unicode: positional scan (no chain collision).

        Scans each character exactly once with 2-byte lookahead.
        Avoids the old text.replace() approach where Unicode output
        chars (e.g. ã U+00E3) collided with TCVN3 table keys
        (chr(0xE3) → ó), causing chain corruption like ã→ó→ú→ỳ.
        """
        result = []
        i = 0
        length = len(text)
        while i < length:
            # Try 2-byte match first (uppercase accented: base + accent)
            if i + 1 < length:
                pair = text[i:i + 2]
                if pair in _TCVN2UNI_2:
                    result.append(_TCVN2UNI_2[pair])
                    i += 2
                    continue
            # Single-byte match
            ch = text[i]
            if ch in _TCVN2UNI_1:
                result.append(_TCVN2UNI_1[ch])
            else:
                result.append(ch)
            i += 1
        return ''.join(result)

    @staticmethod
    def _convert_unicode_to_tcvn3(text):
        """Unicode → TCVN3: single Unicode char → 1 or 2 byte TCVN3 sequence."""
        result = []
        for ch in text:
            if ch in _UNI2TCVN:
                result.append(_UNI2TCVN[ch])
            else:
                result.append(ch)
        return ''.join(result)

    @staticmethod
    def _convert_vni_to_unicode(text):
        """VNI → Unicode: multi-char sequences first, then single-char."""
        _vni2 = [
            ('aâ', 'â'),
            ('AÂ', 'Â'),
            ('aê', 'ă'),
            ('AÊ', 'Ă'),
            ('eâ', 'ê'),
            ('EÂ', 'Ê'),
            ('aù', 'á'),
            ('AÙ', 'Á'),
            ('aø', 'à'),
            ('AØ', 'À'),
            ('aû', 'ả'),
            ('AÛ', 'Ả'),
            ('aõ', 'ã'),
            ('AÕ', 'Ã'),
            ('aï', 'ạ'),
            ('AÏ', 'Ạ'),
            ('aá', 'ấ'),
            ('AÁ', 'Ấ'),
            ('aà', 'ầ'),
            ('AÀ', 'Ầ'),
            ('aå', 'ẩ'),
            ('AÅ', 'Ẩ'),
            ('aã', 'ẫ'),
            ('AÃ', 'Ẫ'),
            ('aä', 'ậ'),
            ('AÄ', 'Ậ'),
            ('aé', 'ắ'),
            ('AÉ', 'Ắ'),
            ('aè', 'ằ'),
            ('AÈ', 'Ằ'),
            ('aú', 'ẳ'),
            ('AÚ', 'Ẳ'),
            ('aü', 'ẵ'),
            ('AÜ', 'Ẵ'),
            ('aë', 'ặ'),
            ('AË', 'Ặ'),
            ('eù', 'é'),
            ('EÙ', 'É'),
            ('eø', 'è'),
            ('EØ', 'È'),
            ('eû', 'ẻ'),
            ('EÛ', 'Ẻ'),
            ('eõ', 'ẽ'),
            ('EÕ', 'Ẽ'),
            ('eï', 'ẹ'),
            ('EÏ', 'Ẹ'),
            ('eá', 'ế'),
            ('EÁ', 'Ế'),
            ('eà', 'ề'),
            ('EÀ', 'Ề'),
            ('eå', 'ể'),
            ('EÅ', 'Ể'),
            ('eã', 'ễ'),
            ('EÃ', 'Ễ'),
            ('eä', 'ệ'),
            ('EÄ', 'Ệ'),
            ('oû', 'ỏ'),
            ('OÛ', 'Ỏ'),
            ('oõ', 'õ'),
            ('OÕ', 'Õ'),
            ('oï', 'ọ'),
            ('OÏ', 'Ọ'),
            ('oá', 'ố'),
            ('OÁ', 'Ố'),
            ('oà', 'ồ'),
            ('OÀ', 'Ồ'),
            ('oå', 'ổ'),
            ('OÅ', 'Ổ'),
            ('oã', 'ỗ'),
            ('OÃ', 'Ỗ'),
            ('oä', 'ộ'),
            ('OÄ', 'Ộ'),
            ('ôù', 'ớ'),
            ('ÔÙ', 'Ớ'),
            ('ôø', 'ờ'),
            ('ÔØ', 'Ờ'),
            ('ôû', 'ở'),
            ('ÔÛ', 'Ở'),
            ('ôõ', 'ỡ'),
            ('ÔÕ', 'Ỡ'),
            ('ôï', 'ợ'),
            ('ÔÏ', 'Ợ'),
            ('uù', 'ú'),
            ('UÙ', 'Ú'),
            ('uø', 'ù'),
            ('UØ', 'Ù'),
            ('uû', 'ủ'),
            ('UÛ', 'Ủ'),
            ('uõ', 'ũ'),
            ('UÕ', 'Ũ'),
            ('uï', 'ụ'),
            ('UÏ', 'Ụ'),
            ('öù', 'ứ'),
            ('ÖÙ', 'Ứ'),
            ('öø', 'ừ'),
            ('ÖØ', 'Ừ'),
            ('öû', 'ử'),
            ('ÖÛ', 'Ử'),
            ('öõ', 'ữ'),
            ('ÖÕ', 'Ữ'),
            ('öï', 'ự'),
            ('ÖÏ', 'Ự'),
            ('yø', 'ỳ'),
            ('YØ', 'Ỳ'),
            ('yû', 'ỷ'),
            ('YÛ', 'Ỷ'),
            ('yõ', 'ỹ'),
            ('YÕ', 'Ỹ'),
            ('yù', 'ý'),
            ('YÙ', 'Ý'),
            ('où', 'ó'),
            ('OÙ', 'Ó'),
            ('oø', 'ò'),
            ('OØ', 'Ò'),
            ('oâ', 'ô'),
            ('OÂ', 'Ô'),]
        _vni1 = [
            ('ñ', 'đ'), ('Ñ', 'Đ'), ('í', 'í'), ('Í', 'Í'),
            ('ì', 'ì'), ('Ì', 'Ì'), ('æ', 'ỉ'), ('Æ', 'Ỉ'),
            ('ö', 'ư'), ('Ö', 'Ư'), ('î', 'ỵ'), ('Î', 'Ỵ'),
        ]
        for old, new in _vni2:
            text = text.replace(old, new)
        for old, new in _vni1:
            text = text.replace(old, new)
        return text

    # ═══════════════════════════════════════════════════════════════
    # Mode 4: Fix Mojibake (reverse CP1252 double-encoding)
    # Based on Cam Lộ, Quảng Trị case study (07/2026)
    # ═══════════════════════════════════════════════════════════════

    # Common mojibake patterns where CP1252 has undefined bytes (\x81,\x8d,\x8f,\x90,\x9d)
    # GDAL replaces these with U+FFFD. We must pattern-match to recover.
    _MOJIBAKE_PATTERNS = [
        # (garbled text when GDAL reads UTF-8 as CP1252, correct UTF-8)
        # Đ = \xc4\x90 → CP1252: Ä + \x90(undef→\ufffd)
        ('Ä\ufffd', 'Đ'),
        ('Ã\ufffd', 'đ'),    # đ = \xc4\x91 → no, đ=\xc4\x91 → Ä\u0091='
        # ờ ending: \xe1\xbb\x9d → á»\x9d → á»\ufffd
        ('á»\ufffd', 'ờ'),
        # ở: \xe1\xbb\x9f → á»\x9f → á»\ufffd (same pattern but different char)
        # We'll handle context-dependent resolution below
    ]

    # Full mapping: UTF-8 3-byte sequences that contain 0x8D,0x8F,0x90,0x9D bytes
    # These bytes are undefined in CP1252, GDAL replaces with \ufffd
    _MOJIBAKE_TRIGRAM = {
        # Pattern: (cp1252 char before \ufffd, cp1252 char after \ufffd) → original UTF-8 char
        # Đ: C4 90 → Ä + \ufffd
        'Ä\ufffd': 'Đ',
        # đ: C4 91 → Ä + ' (0x91 = LEFT SINGLE QUOTE in CP1252)
        # This one actually works with Mode 4 since 0x91 IS defined in CP1252
    }

    def _fix_mojibake(self, text):
        """Fix double-encoded UTF-8 text (UTF-8 bytes misread as CP1252).

        When GDAL reads UTF-8 bytes as CP1252, it produces garbled chars.
        Handles two scenarios:
        1. Simple: all bytes defined in CP1252 → reverse encode works
        2. Complex: some bytes (0x81,0x8D,0x8F,0x90,0x9D) are undefined
           in CP1252 → GDAL replaces with \ufffd → need pattern matching
        """
        if not text:
            return text

        # Check if text contains replacement characters (GDAL-corrupted bytes)
        has_replacement = '\ufffd' in text

        if not has_replacement:
            # Simple case: try direct reverse encoding
            try:
                fixed = text.encode('cp1252').decode('utf-8')
                return fixed
            except (UnicodeDecodeError, UnicodeEncodeError):
                try:
                    fixed = text.encode('latin-1').decode('utf-8')
                    return fixed
                except (UnicodeDecodeError, UnicodeEncodeError):
                    return text

        # Complex case: has \ufffd → need to reconstruct missing bytes
        # Strategy: replace \ufffd with candidate bytes and try decoding
        return self._fix_mojibake_with_replacement(text)

    def _fix_mojibake_with_replacement(self, text):
        """Fix mojibake text that contains \ufffd replacement characters.

        GDAL replaces CP1252-undefined bytes (0x81,0x8D,0x8F,0x90,0x9D)
        with U+FFFD. We try each candidate byte to reconstruct valid UTF-8.
        """
        # The undefined bytes in CP1252 that GDAL replaces:
        candidate_bytes = [0x81, 0x8D, 0x8F, 0x90, 0x9D]

        # Build the base byte array with placeholders for \ufffd positions
        result_chars = []
        ufffd_positions = []

        for i, ch in enumerate(text):
            if ch == '\ufffd':
                ufffd_positions.append(len(result_chars))
                result_chars.append(None)  # placeholder
            else:
                try:
                    b = ch.encode('cp1252')
                    result_chars.append(b[0])
                except (UnicodeEncodeError, UnicodeDecodeError):
                    try:
                        b = ch.encode('latin-1')
                        result_chars.append(b[0])
                    except Exception:
                        # Can't encode this char — give up on it
                        result_chars.append(ord('?'))

        if not ufffd_positions:
            return text

        # Try all combinations of candidate bytes for \ufffd positions
        # For efficiency, try each position independently first
        from itertools import product

        # Limit combinations to avoid explosion
        if len(ufffd_positions) > 8:
            # Too many — try most common byte (0x90 for Đ, 0x9D for ờ)
            combos = [[0x90] * len(ufffd_positions),
                      [0x9D] * len(ufffd_positions)]
        else:
            combos = product(candidate_bytes, repeat=len(ufffd_positions))

        for combo in combos:
            test_bytes = list(result_chars)
            for pos, byte_val in zip(ufffd_positions, combo):
                test_bytes[pos] = byte_val

            try:
                raw = bytes(test_bytes)
                decoded = raw.decode('utf-8')
                # Validate: should contain Vietnamese chars, not control chars
                if all(ord(c) > 31 or c in '\n\r\t' for c in decoded):
                    return decoded
            except (UnicodeDecodeError, ValueError):
                continue

        # If no combination works, try the original simple approach
        # (will likely return original text)
        return text

    # ═══════════════════════════════════════════════════════════════
    # Mode 5: Raw DAT Bypass (bypass GDAL — read binary DAT/DBF)
    # For Triple Mojibake when mode 4 cannot recover original text
    # ═══════════════════════════════════════════════════════════════
    def _fix_mojibake_raw(self, layer, path, driver, ext, target_crs):
        """Bypass GDAL entirely — read raw bytes from original DAT/DBF file.

        This handles cases where mode 4 fails because GDAL has irreversibly
        corrupted some bytes (0x90, 0x9D are undefined in CP1252).
        Reads the original .DAT (dBASE III) at binary level,
        decodes text as UTF-8, and writes a new output file.

        If the source file is not a MapInfo TAB (e.g., already exported SHP),
        prompts the user to select the original DAT/DBF file.
        """
        source_path = layer.source().split('|')[0]

        # Find the .DAT/.DBF file
        base = os.path.splitext(source_path)[0]
        dat_path = None
        for ext_try in ['.DAT', '.dat', '.DBF', '.dbf']:
            candidate = base + ext_try
            if os.path.exists(candidate):
                dat_path = candidate
                break

        # Validate: if source is a SHP (already processed), the DBF is
        # already corrupted. Ask user for original DAT file.
        source_ext = os.path.splitext(source_path)[1].lower()
        need_user_select = False

        if dat_path is None:
            need_user_select = True
        elif source_ext in ['.shp', '.geojson', '.gpkg']:
            # Source is a derived file — its DBF is already corrupted
            need_user_select = True
            self.log.append(
                "⚠️ Source is a SHP/GeoJSON — its DBF is already corrupted.")
            self.log.append(
                "   Please select the ORIGINAL MapInfo .DAT file.")

        if need_user_select:
            dat_path, _ = QFileDialog.getOpenFileName(
                self,
                "Select original MapInfo DAT/DBF file "
                "(file gốc chưa bị xử lý)",
                os.path.dirname(source_path),
                "Data files (*.DAT *.dat *.DBF *.dbf);;All files (*)")
            if not dat_path:
                raise FileNotFoundError(
                    "User cancelled: no original DAT/DBF file selected")

        self.log.append(
            f"📂 Reading raw bytes from: {os.path.basename(dat_path)}")

        # Read raw bytes from DAT (DBF format)
        with open(dat_path, 'rb') as f:
            header = f.read(32)
            if len(header) < 32:
                raise ValueError("Invalid DAT/DBF file (header too short)")

            num_records = struct.unpack('<I', header[4:8])[0]
            header_size = struct.unpack('<H', header[8:10])[0]
            record_size = struct.unpack('<H', header[10:12])[0]

            # Read field definitions
            fields = []
            f.seek(32)
            while f.tell() < header_size - 1:
                fd = f.read(32)
                if len(fd) < 32 or fd[0] == 0x0D:
                    break
                name = fd[:11].split(b'\x00')[0].decode('ascii')
                ftype = chr(fd[11])
                flen = fd[16]
                fields.append({'name': name, 'type': ftype, 'length': flen})

            f.seek(header_size)

            # Read records and decode text fields
            records = []
            for rec_idx in range(num_records):
                rec = f.read(record_size)
                if len(rec) < record_size:
                    break
                offset = 1  # Skip deletion flag
                rec_dict = {}
                for field in fields:
                    val = rec[offset:offset + field['length']]
                    offset += field['length']
                    if field['type'] == 'C':
                        clean = val.rstrip(b' \x00')
                        # Try multiple encodings in order of preference
                        decoded = None
                        for enc in ['utf-8', 'windows-1258', 'cp1252']:
                            try:
                                decoded = clean.decode(enc)
                                break
                            except (UnicodeDecodeError, LookupError):
                                continue
                        if decoded is None:
                            decoded = clean.decode('utf-8', errors='replace')
                        rec_dict[field['name']] = decoded
                records.append(rec_dict)

        text_field_count = sum(1 for fld in fields if fld['type'] == 'C')
        self.log.append(
            f"   Records: {num_records}, Text fields: {text_field_count}")

        # Verify record count matches layer features
        features = list(layer.getFeatures())
        if len(features) != len(records):
            self.log.append(
                f"⚠️ Record count mismatch: layer={len(features)}, "
                f"DAT={len(records)}. Using min.")

        # Build output using QgsVectorFileWriter with patched text
        writer = QgsVectorFileWriter(
            path, 'UTF-8',
            layer.fields(), layer.wkbType(), target_crs,
            driver
        )
        if writer.hasError() != QgsVectorFileWriter.WriterError.NoError:
            raise RuntimeError(f"Writer error: {writer.errorMessage()}")

        layer_field_names = [fld.name() for fld in layer.fields()]
        patched = 0
        for i, feat in enumerate(features):
            if i < len(records):
                for field in fields:
                    if field['type'] == 'C':
                        fn = field['name']
                        if fn in layer_field_names:
                            raw_val = records[i].get(fn, '')
                            if feat[fn] != raw_val:
                                feat[fn] = raw_val
                                patched += 1
            writer.addFeature(feat)

        del writer
        self.log.append(f"🔧 Patched {patched} text values from raw DAT bytes")

    def _show_help(self):
        """Open help dialog for Font Converter module."""
        from ..shared.help_dialog import HelpDialog
        dlg = HelpDialog("font_converter", parent=self)
        dlg.exec()
