from core.utils import get_company_root_path
"""
contract_module.py — Module Tạo Hợp Đồng & Bảng Kê (Refactored from hkd_tax_module.py)
Hỗ trợ tạo hợp đồng từ mẫu Word (.docx), quản lý danh sách sản phẩm và đồng bộ Google Sheets/Excel.
"""
import os
import shutil
import subprocess
import sys
import json
import re
import random
import string
import sqlite3
from datetime import datetime
from PySide6.QtWidgets import (
    QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame,
    QLineEdit, QComboBox, QPushButton, QScrollArea,
    QGroupBox, QGridLayout, QMessageBox, QFileDialog,
    QTabWidget, QTextEdit, QTableWidget, QTableWidgetItem, QDateEdit,
    QHeaderView, QAbstractItemView, QStyledItemDelegate, QCheckBox
)
from PySide6.QtCore import Qt, QDate, QTimer, QStringListModel
from PySide6.QtGui import QFont, QPixmap, QPainter, QColor, QIcon
from PySide6.QtWidgets import QCompleter

from gui.theme import COLORS
from gui.ui_helpers import (
    SP, PrimaryButton, SecondaryButton, DangerButton, GhostButton,
    make_input, make_combo, make_form, make_group,
    CardFrame, apply_table_style, IconButton, FilterBar, PageHeader,
    show_toast, _load_icon
)

# Backend imports
_SRC = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if _SRC not in sys.path:
    sys.path.insert(0, _SRC)

from core.utils import (
    CONTRACT_DOWNLOAD_URL,
    CONTRACT_GUIDE_FILENAME,
    build_contract_software_guide_block,
    get_contract_guide_path,
    get_app_data_path,
    get_resource_path,
)
from core.contract_engine import generate_contract, format_product_list
from core.drive_sync import sync_contracts_with_metadata
from core.num2words_vn import number_to_vietnamese_words
from PySide6.QtCore import Qt, QDate, QTimer, QUrl, QThread, Signal


def _coerce_number(value) -> float:
    if value is None:
        return 0.0
    if isinstance(value, (int, float)):
        try:
            return float(value)
        except Exception:
            return 0.0

    text = str(value).strip()
    if not text:
        return 0.0

    text = (
        text.replace("VND", "")
        .replace("VNĐ", "")
        .replace("VNÐ", "")
        .replace("đ", "")
        .replace("Đ", "")
        .replace("%", "")
        .replace("\u00a0", "")
        .replace(" ", "")
    )

    sci_text = text.replace(",", ".")
    if re.fullmatch(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)[eE][+-]?\d+", sci_text):
        try:
            return float(sci_text)
        except Exception:
            return 0.0

    text = re.sub(r"[^0-9,.\-]", "", text)
    if not text or text in {"-", ".", ","}:
        return 0.0

    if "," in text and "." in text:
        decimal_sep = "," if text.rfind(",") > text.rfind(".") else "."
        thousands_sep = "." if decimal_sep == "," else ","
        text = text.replace(thousands_sep, "")
        text = text.replace(decimal_sep, ".")
    elif "," in text:
        parts = text.split(",")
        if len(parts) > 2 or (len(parts[-1]) == 3 and all(part.isdigit() for part in parts if part)):
            text = "".join(parts)
        else:
            text = text.replace(",", ".")
    elif "." in text:
        parts = text.split(".")
        if len(parts) > 2 or (len(parts[-1]) == 3 and all(part.isdigit() for part in parts if part)):
            text = "".join(parts)

    try:
        return float(text)
    except Exception:
        return 0.0


def _format_plain_number(value, max_decimals: int = 2, group: bool = False) -> str:
    number = _coerce_number(value)
    if abs(number) < 1e-12:
        number = 0.0

    if float(number).is_integer():
        return f"{int(round(number)):,}" if group else str(int(round(number)))

    decimals = max(0, int(max_decimals))
    text = f"{number:,.{decimals}f}" if group else f"{number:.{decimals}f}"
    return text.rstrip("0").rstrip(".")


class SyncWorker(QThread):
    finished_sig = Signal(dict)
    
    def __init__(self, category):
        super().__init__()
        self.category = category
        
    def run(self):
        try:
            from core.drive_sync import sync_contracts_with_metadata
            res = sync_contracts_with_metadata(self.category)
            self.finished_sig.emit(res)
        except Exception as e:
            self.finished_sig.emit({"success": False, "message": str(e)})


class MailCheckWorker(QThread):
    finished_sig = Signal(dict)

    def __init__(self, page, category):
        super().__init__()
        self.page = page
        self.category = category

    def run(self):
        try:
            res = self.page._sync_imap_contracts(self.category, contracts_data=[])
            if not isinstance(res, dict):
                res = {"success": False, "message": "Kết quả kiểm tra mail không hợp lệ."}
            self.finished_sig.emit(res)
        except Exception as e:
            self.finished_sig.emit({"success": False, "message": str(e)})

class GenerateWorker(QThread):
    finished_sig = Signal(bool, str) # success, file_path_or_err
    
    def __init__(self, template_path, docx_path, context, to_pdf=False):
        super().__init__()
        self.template_path = template_path
        self.docx_path = docx_path
        self.context = context
        self.to_pdf = to_pdf

    def _convert_docx_to_pdf_via_word(self, pdf_path: str):
        import pythoncom
        import win32com.client

        word = None
        document = None
        source_path = os.path.abspath(self.docx_path)
        target_path = os.path.abspath(pdf_path)

        pythoncom.CoInitialize()
        try:
            word = win32com.client.DispatchEx("Word.Application")
            word.Visible = False
            word.DisplayAlerts = 0
            document = word.Documents.Open(source_path, ReadOnly=1)
            document.SaveAs(target_path, FileFormat=17)
        finally:
            if document is not None:
                try:
                    document.Close(False)
                except Exception:
                    pass
            if word is not None:
                try:
                    word.Quit()
                except Exception:
                    pass
            pythoncom.CoUninitialize()

    def _find_soffice_executable(self) -> str | None:
        candidates = [
            shutil.which("soffice"),
            shutil.which("soffice.exe"),
            shutil.which("libreoffice"),
            shutil.which("libreoffice.exe"),
            r"C:\Program Files\LibreOffice\program\soffice.exe",
            r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
        ]
        for candidate in candidates:
            if candidate and os.path.exists(candidate):
                return candidate
        return None

    def _convert_docx_to_pdf_via_libreoffice(self, pdf_path: str):
        soffice = self._find_soffice_executable()
        if not soffice:
            raise FileNotFoundError("Không tìm thấy LibreOffice (soffice).")

        source_path = os.path.abspath(self.docx_path)
        output_dir = os.path.dirname(os.path.abspath(pdf_path))
        expected_pdf = os.path.join(
            output_dir,
            os.path.splitext(os.path.basename(source_path))[0] + ".pdf",
        )

        result = subprocess.run(
            [
                soffice,
                "--headless",
                "--convert-to",
                "pdf",
                "--outdir",
                output_dir,
                source_path,
            ],
            capture_output=True,
            text=True,
            timeout=120,
            check=False,
        )

        if result.returncode != 0:
            detail = (result.stderr or result.stdout or "").strip()
            raise RuntimeError(detail or "LibreOffice không chuyển được file sang PDF.")

        if not os.path.exists(expected_pdf):
            raise FileNotFoundError(f"LibreOffice không tạo ra file PDF: {expected_pdf}")

        target_path = os.path.abspath(pdf_path)
        if os.path.normcase(expected_pdf) != os.path.normcase(target_path):
            if os.path.exists(target_path):
                os.remove(target_path)
            os.replace(expected_pdf, target_path)

    def _convert_docx_to_pdf(self, pdf_path: str):
        word_error = None
        try:
            self._convert_docx_to_pdf_via_word(pdf_path)
            return
        except Exception as exc:
            word_error = exc

        try:
            self._convert_docx_to_pdf_via_libreoffice(pdf_path)
        except Exception as libre_error:
            raise RuntimeError(
                f"Word: {word_error}; LibreOffice: {libre_error}"
            ) from libre_error
        
    def run(self):
        try:
            from core.contract_engine import generate_contract
            generate_contract(self.template_path, self.docx_path, self.context)
            if self.to_pdf:
                # _tmp.docx → .pdf
                pdf_path = self.docx_path.replace('_tmp.docx', '.pdf')
                try:
                    self._convert_docx_to_pdf(pdf_path)
                    self.finished_sig.emit(True, pdf_path)
                    
                    # Xóa file docx tmp sau khi đã tạo PDF thành công
                    try:
                        import os
                        if os.path.exists(self.docx_path):
                            os.remove(self.docx_path)
                    except Exception:
                        pass
                except Exception as e:
                    # Đổi tên file docx lại bình thường
                    fallback_path = self.docx_path.replace('_tmp.docx', '.docx')
                    try:
                        import os
                        if os.path.exists(self.docx_path):
                            os.rename(self.docx_path, fallback_path)
                    except Exception:
                        pass
                    # Báo lỗi để UI xử lý fallback (code phải đúng __DOCX_FALLBACK__)
                    self.finished_sig.emit(True, f"__DOCX_FALLBACK__|{fallback_path}|{e}")
            else:
                self.finished_sig.emit(True, self.docx_path)
        except Exception as e:
            self.finished_sig.emit(False, f"Lỗi tạo tài liệu: {e}")


class MSTLookupWorker(QThread):
    finished_sig = Signal(bool, dict, str)

    def __init__(self, mst: str):
        super().__init__()
        self.mst = mst.strip()

    def run(self):
        try:
            from core.api_mst import lookup_mst
            biz = lookup_mst(self.mst)
            if biz:
                self.finished_sig.emit(True, biz, "")
            else:
                self.finished_sig.emit(False, {}, f"Không tìm thấy thông tin cho MST: {self.mst}")
        except Exception as e:
            self.finished_sig.emit(False, {}, str(e))


class CatalogLoadWorker(QThread):
    finished_sig = Signal(bool, dict, str)

    def __init__(self, mode: str, payload: str):
        super().__init__()
        self.mode = mode  # excel|sheets
        self.payload = payload

    def run(self):
        try:
            if self.mode == "excel":
                data = self._load_from_excel(self.payload)
            elif self.mode == "sheets":
                data = self._load_from_sheets(self.payload)
            else:
                raise ValueError(f"Chế độ không hỗ trợ: {self.mode}")
            self.finished_sig.emit(True, data, "")
        except Exception as e:
            self.finished_sig.emit(False, {}, str(e))

    @staticmethod
    def _safe_float(val):
        return _coerce_number(val)

    def _load_from_excel(self, path: str) -> dict:
        import openpyxl

        wb = openpyxl.load_workbook(path, data_only=True)
        ws = wb.active
        catalog = {}
        for row in ws.iter_rows(min_row=2, values_only=True):
            if not any(row):
                continue
            name = str(row[1]).strip() if len(row) > 1 and row[1] else ""
            if name:
                catalog[name] = {
                    'unit': str(row[2]) if len(row) > 2 and row[2] else "",
                    'price': self._safe_float(row[4]) if len(row) > 4 else 0,
                    'vat': self._safe_float(row[5]) if len(row) > 5 else 0,
                }
        return catalog

    def _load_from_sheets(self, link: str) -> dict:
        import urllib.request
        import csv
        import io

        m = re.search(r'/spreadsheets/d/([a-zA-Z0-9_-]+)', link)
        if not m:
            raise ValueError("Link không hợp lệ. Vui lòng dán link Google Sheets.")

        sheet_id = m.group(1)
        csv_url = f"https://docs.google.com/spreadsheets/d/{sheet_id}/export?format=csv"

        req = urllib.request.Request(csv_url, headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=20) as resp:
            content = resp.read().decode("utf-8", errors="ignore")

        reader = csv.reader(io.StringIO(content))
        rows = list(reader)
        if len(rows) < 2:
            return {}

        catalog = {}
        for row in rows[1:]:
            name = str(row[1]).strip() if len(row) > 1 and row[1] else ""
            if name:
                catalog[name] = {
                    'unit': str(row[2]) if len(row) > 2 else "",
                    'price': self._safe_float(row[4]) if len(row) > 4 else 0,
                    'vat': self._safe_float(row[5]) if len(row) > 5 else 0,
                }
        return catalog

class CompleterDelegate(QStyledItemDelegate):
    """Delegate to show a QCompleter in a QTableWidget cell."""
    def __init__(self, completer, parent=None):
        super().__init__(parent)
        self.completer = completer

    def createEditor(self, parent, option, index):
        editor = QLineEdit(parent)
        editor.setPlaceholderText("chọn sản phẩm")
        editor.setCompleter(self.completer)
        return editor

    def paint(self, painter, option, index):
        if not index.data():
            # Mượn style mặc định
            super().paint(painter, option, index)
            # Vẽ thêm placeholder text mờ
            painter.save()
            # Sử dụng màu text mờ từ palette hoặc default gray
            painter.setPen(option.palette.placeholderText().color())
            painter.drawText(option.rect.adjusted(5, 0, -5, 0), Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, "chọn sản phẩm")
            painter.restore()
        else:
            super().paint(painter, option, index)

class HKDTaxPage(QWidget):
    """Module Tạo Hợp Đồng (Giữ tên class cũ để tương thích với app.py)."""

    def __init__(self, parent=None):
        super().__init__(parent)
        self.setObjectName("content_area")
        self.contract_dir = self._resolve_contract_dir()
        self.product_dir = self._resolve_product_dir()
        self.default_product_catalog_path = os.path.join(self.product_dir, "sanpham.xlsx")
        self.data_dir = os.path.join(os.path.dirname(_SRC), "data")
        self._is_updating_table = False
        self.product_catalog = {} # Name -> {unit, price, vat}
        self.product_names = []
        self._partners_cache: list[dict] = []
        # Lấy db active từ company_manager khi init
        try:
            from core.company_manager import _get_default_db_path
            self._company_db_path = _get_default_db_path() or ''
        except Exception:
            self._company_db_path = ''
        self.completer = QCompleter()
        self.completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
        self.completer.setFilterMode(Qt.MatchFlag.MatchContains)
        self._mst_worker = None
        self._catalog_worker = None
        self._mail_sent_keys: set[str] = set()
        self._mail_sent_icon_cache = None
        self._btn_check_mail = {}
        self._mail_check_workers = []
        
        root = QVBoxLayout(self)
        root.setContentsMargins(16, 16, 16, 16)
        root.setSpacing(0)

        # ── Body ───────────────────────────────────────────
        self.tabs = QTabWidget()
        self.tabs.setObjectName("tab_main")
        
        # Tab 1: Tạo hợp đồng
        self.setup_tab = self._build_contract_tab()
        self.tabs.addTab(self.setup_tab, "📝  Tạo hợp đồng")

        # Tab 2: Hợp đồng bán hàng
        self.sales_tab = self._build_list_tab("Hợp đồng bán hàng", "sale")
        self.tabs.addTab(self.sales_tab, "📈  Hợp đồng bán hàng")

        # Tab 3: Hợp đồng mua hàng
        self.purchase_tab = self._build_list_tab("Hợp đồng mua hàng", "purchase")
        self.tabs.addTab(self.purchase_tab, "🛒  Hợp đồng mua hàng")

        # Tab 4: Hợp đồng khác (quản lý danh sách giống sale/purchase)
        self.other_tab = self._build_list_tab("Hợp đồng khác", "other")
        self.tabs.addTab(self.other_tab, "📂  Hợp đồng khác")

        # Tab 5: Ký linh hoạt theo vị trí (ẩn khỏi tab bar, vẫn build để giữ widgets)
        self.other_contract_tab = self._build_other_contract_tab()

        root.addWidget(self.tabs)

        # Auto-load cấu hình công ty và mẫu mã hợp đồng từ info.json
        QTimer.singleShot(300, self._auto_load_company_config)

        # Đồng bộ Google Drive chỉ chạy khi người dùng chủ động bấm nút.

        # Chỉ hiển thị nút Kiểm tra Mail khi cấu hình mail đã đầy đủ.
        self._update_check_mail_buttons_visibility()

    def _auto_sync_all(self):
        """Giữ lại để tương thích; không tự đồng bộ Google Drive nữa."""
        return

    def _contract_mail_key(self, contract_item: dict, category: str) -> str:
        """Tạo key ổn định để ghi nhận trạng thái đã gửi mail cho từng hợp đồng."""
        c_id = str(contract_item.get("id", "")).strip()
        if c_id:
            return f"{category}:{c_id}"
        return f"{category}:{contract_item.get('date', '')}:{contract_item.get('name_file', '')}"

    def _apply_mail_sent_style(self, btn):
        """Đổi icon mail sang màu xanh lá sau khi gửi thành công."""
        btn.setToolTip("Đã gửi Mail")
        btn.setStyleSheet("")
        btn.setIcon(self._get_mail_sent_icon())

    def _get_mail_sent_icon(self):
        """Tạo icon mail màu xanh lá từ icon gốc để báo đã gửi mail."""
        if self._mail_sent_icon_cache is not None:
            return self._mail_sent_icon_cache

        base_icon = _load_icon("action_mail_128.png")
        pm = base_icon.pixmap(20, 20)
        if pm.isNull():
            self._mail_sent_icon_cache = base_icon
            return self._mail_sent_icon_cache

        out = QPixmap(pm.size())
        out.fill(Qt.GlobalColor.transparent)
        painter = QPainter(out)
        painter.drawPixmap(0, 0, pm)
        painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
        painter.fillRect(out.rect(), QColor("#16a34a"))
        painter.end()

        self._mail_sent_icon_cache = QIcon(out)
        return self._mail_sent_icon_cache

    def _has_complete_mail_config(self) -> bool:
        """Kiểm tra cấu hình mail đã đủ thông tin cần thiết trong Settings hay chưa."""
        try:
            from core.utils import get_app_data_path
            settings_path = get_app_data_path("settings.json")
            if not os.path.exists(settings_path):
                return False
            with open(settings_path, "r", encoding="utf-8") as f:
                cfg = json.load(f).get("company", {})
            m = cfg.get("mail_config", {})
            required = ["imap_server", "imap_port", "smtp_server", "smtp_port", "user", "pass"]
            return all(str(m.get(k, "")).strip() for k in required)
        except Exception:
            return False

    def _plan_has_feature(self, feature_key: str) -> bool:
        from core.licensing import get_effective_plan_limits

        main_win = self.window()
        plan_id = getattr(main_win, "plan_type", "free") if main_win else "free"
        license_context = getattr(main_win, "license_context", {}) if main_win else {}
        limits = getattr(main_win, "effective_plan_limits", {}) or get_effective_plan_limits(plan_id, license_context)
        return bool(limits.get(feature_key))

    def _ensure_plan_feature(self, feature_key: str, title: str, allowed_plans: str) -> bool:
        from core.licensing import format_plan_label

        if self._plan_has_feature(feature_key):
            return True
        main_win = self.window()
        plan_id = getattr(main_win, "plan_type", "free") if main_win else "free"
        QMessageBox.warning(
            self,
            title,
            f"Gói hiện tại ({format_plan_label(plan_id)}) chưa bao gồm tính năng này.\n"
            f"Vui lòng dùng {allowed_plans} để tiếp tục.",
        )
        return False

    def _update_check_mail_buttons_visibility(self):
        """Ẩn/hiện nút Kiểm tra Mail theo trạng thái cấu hình mail."""
        visible = self._has_complete_mail_config()
        for btn in self._btn_check_mail.values():
            btn.setVisible(visible)

    def refresh(self, db_path: str = ""):
        """Nhận db_path từ app → reload danh sách khách hàng cho completer."""
        if db_path:
            self._company_db_path = db_path
        if hasattr(self, '_load_partners_cache'):
            try:
                self._load_partners_cache()
            except Exception as e:
                print(f"[HKDTaxPage] refresh partners error: {e}")
        self._update_check_mail_buttons_visibility()
        # Reload danh sách hợp đồng
        try:
            self._reload_table("sale")
            self._reload_table("purchase")
            self._reload_table("other")
        except Exception:
            pass

    def _category_table_name(self, category: str) -> str:
        category = (category or "").strip().lower()
        if category == "sale":
            return "contracts_sale"
        if category == "purchase":
            return "contracts_purchase"
        return "contracts_other"

    def _category_dir_name(self, category: str) -> str:
        return self._category_table_name(category)

        # Cập nhật gợi ý format mã hợp đồng theo cấu hình hiện tại.
        main = self.window()
        if hasattr(main, "company_info") and hasattr(self, "input_code"):
            fmt = main.company_info.get("contract_format", "HD-{YYYY}{MM}{DD}-{RAND4}")
            self.input_code.setPlaceholderText(f"Định dạng: {fmt}")

    def _ensure_mail_software_guide(self, body_template: str) -> str:
        template = (body_template or "").strip()
        if not template:
            return template

        guide_tokens = (
            "phần mềm ký pdf đề xuất",
            "phần mềm ký hợp đồng ini hd",
            "link tải phần mềm",
            "file hướng dẫn cài đặt",
            "get.adobe.com/reader",
            "helpx.adobe.com/acrobat/using/signing-pdfs.html",
            CONTRACT_DOWNLOAD_URL.lower(),
            CONTRACT_GUIDE_FILENAME.lower(),
        )
        filtered_lines = []
        for line in template.splitlines():
            if any(token in line.lower() for token in guide_tokens):
                continue
            filtered_lines.append(line)

        template = re.sub(r"\n{3,}", "\n\n", "\n".join(filtered_lines).strip())
        guide_block = build_contract_software_guide_block()
        if not template:
            return guide_block

        marker = "📎 Lưu ý khi gửi lại:\n"
        if marker in template:
            return template.replace(marker, guide_block + "\n\n" + marker, 1)
        return template.rstrip() + "\n\n" + guide_block

    def _get_contract_guide_path(self) -> str:
        guide_path = get_contract_guide_path()
        return guide_path if os.path.exists(guide_path) else ""

    def _save_generated_contract_metadata(self, category: str, file_path: str, context: dict, date_str: str, c_id: str):
        from core.contracts_db import get_connection, write_contract_sidecar

        db_table = self._category_table_name(category)
        company_name = str(context.get("ten_ben_b") or "").strip()
        company_mst = str(context.get("mst_ben_b") or context.get("ma_so_thue") or "").strip()
        partner_name = str(context.get("ten_khach_hang") or "").strip()
        partner_mst = str(context.get("mst") or "").strip()
        amount_total = self._safe_float(context.get("tong_tien_so"))

        if category == "sale":
            party_a, party_b = partner_name, company_name
            mst_a, mst_b = partner_mst, company_mst
        elif category == "purchase":
            party_a, party_b = company_name, partner_name
            mst_a, mst_b = company_mst, partner_mst
        else:
            party_a, party_b = partner_name, company_name
            mst_a, mst_b = partner_mst, company_mst

        abs_path = file_path if os.path.isabs(file_path) else get_company_root_path(file_path)
        rel_path = os.path.relpath(os.path.abspath(abs_path), get_company_root_path(""))
        rel_path = rel_path.replace("\\", "/")
        contract_name = os.path.splitext(os.path.basename(getattr(self, "template_path", "") or ""))[0]
        payload = {
            "name_file": c_id,
            "name": contract_name,
            "party_a": party_a,
            "party_b": party_b,
            "mst_a": mst_a,
            "mst_b": mst_b,
            "date": date_str,
            "path": rel_path,
            "sign_sequence": "",
            "note": "",
            "amount_total": amount_total,
            "modified_at": datetime.now().isoformat(timespec="seconds"),
        }

        with get_connection() as conn:
            existing = conn.execute(
                f"SELECT id FROM {db_table} WHERE name_file = ? ORDER BY modified_at DESC LIMIT 1",
                (c_id,),
            ).fetchone()
            if existing:
                update_values = dict(payload)
                update_values["row_id"] = existing["id"]
                assignments = ", ".join(f"{key} = :{key}" for key in payload)
                conn.execute(
                    f"UPDATE {db_table} SET {assignments} WHERE id = :row_id",
                    update_values,
                )
            else:
                columns = ", ".join(payload.keys())
                placeholders = ", ".join(f":{key}" for key in payload)
                conn.execute(
                    f"INSERT INTO {db_table} ({columns}) VALUES ({placeholders})",
                    payload,
                )
            conn.commit()

        try:
            write_contract_sidecar(abs_path, payload)
        except Exception as exc:
            print(f"[HKDTaxPage] cannot write contract sidecar for {c_id}: {exc}")

    def _open_revenue_entry_from_contract(self, contract: dict, category: str, kind: str):
        main = self.window()
        pages = getattr(main, "pages", {}) if main else {}
        revenue_page = pages.get("revenue") if isinstance(pages, dict) else None
        if not main or revenue_page is None or not hasattr(revenue_page, "open_contract_entry"):
            QMessageBox.warning(self.window(), "Không mở được Sổ Thu/Chi", "Không tìm thấy màn hình Sổ Thu/Chi để nạp dữ liệu hợp đồng.")
            return False

        contract_payload = dict(contract or {})
        contract_payload["contract_category"] = category
        if hasattr(main, "switch_page"):
            main.switch_page("revenue")
        revenue_page.open_contract_entry(kind, contract_payload)

        if self._safe_float(contract_payload.get("amount_total")) <= 0:
            show_toast(
                self.window(),
                "Đã nạp dữ liệu hợp đồng. Chưa có số tiền lưu trong hợp đồng, vui lòng nhập bổ sung.",
                type="warning",
            )
        else:
            show_toast(self.window(), "Đã nạp dữ liệu hợp đồng vào Sổ Thu/Chi.")
        return True

    def _prompt_revenue_entry_for_contract(self, contract: dict, category: str):
        if not contract:
            return

        contract_name = contract.get("name_file") or contract.get("name") or "hợp đồng này"
        if category == "sale":
            reply = QMessageBox.question(
                self.window(),
                "Thêm Sổ Thu",
                f"Hợp đồng {contract_name} đã được ký đủ 2 bên.\n\nBạn có muốn thêm khoản Thu dựa theo hợp đồng này không?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.Yes,
            )
            if reply == QMessageBox.StandardButton.Yes:
                self._open_revenue_entry_from_contract(contract, category, "thu")
            return

        if category == "purchase":
            reply = QMessageBox.question(
                self.window(),
                "Thêm Sổ Chi",
                f"Hợp đồng {contract_name} đã được ký đủ 2 bên.\n\nBạn có muốn thêm khoản Chi dựa theo hợp đồng này không?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.Yes,
            )
            if reply == QMessageBox.StandardButton.Yes:
                self._open_revenue_entry_from_contract(contract, category, "chi")
            return

        box = QMessageBox(self.window())
        box.setIcon(QMessageBox.Icon.Question)
        box.setWindowTitle("Thêm Sổ Thu/Chi")
        box.setText(f"Hợp đồng {contract_name} đã được ký đủ 2 bên.")
        box.setInformativeText("Bạn muốn thêm dữ liệu hợp đồng này vào Sổ Thu hay Sổ Chi?")
        btn_thu = box.addButton("Thêm Sổ Thu", QMessageBox.ButtonRole.YesRole)
        btn_chi = box.addButton("Thêm Sổ Chi", QMessageBox.ButtonRole.NoRole)
        box.addButton("Bỏ qua", QMessageBox.ButtonRole.RejectRole)
        box.exec()

        if box.clickedButton() == btn_thu:
            self._open_revenue_entry_from_contract(contract, category, "thu")
        elif box.clickedButton() == btn_chi:
            self._open_revenue_entry_from_contract(contract, category, "chi")

    def _prompt_revenue_entry_for_completed_imports(self, category: str, contract_names: list[str]):
        unique_names: list[str] = []
        seen_names: set[str] = set()
        for name in contract_names:
            normalized = (name or "").strip()
            if normalized and normalized not in seen_names:
                seen_names.add(normalized)
                unique_names.append(normalized)

        if not unique_names:
            return

        selected_name = unique_names[0]
        if len(unique_names) > 1:
            from PySide6.QtWidgets import QInputDialog

            selected_name, ok = QInputDialog.getItem(
                self.window(),
                "Chọn hợp đồng",
                "Đã nhập nhiều hợp đồng ký đủ 2 bên. Chọn 1 hợp đồng để thêm vào Sổ Thu/Chi:",
                unique_names,
                0,
                False,
            )
            if not ok or not selected_name:
                return

        from core.contracts_db import get_contract

        contract = get_contract(self._category_table_name(category), selected_name)
        if contract:
            self._prompt_revenue_entry_for_contract(contract, category)

    def _build_list_tab(self, title: str, category: str) -> QWidget:
        """Tạo tab danh sách hợp đồng."""
        w = QWidget()
        layout = QVBoxLayout(w)
        layout.setContentsMargins(12, 12, 12, 12)
        layout.setSpacing(10)

        from datetime import datetime
        current_year = datetime.now().year
        years = [str(y) for y in range(2025, max(2031, current_year + 2))]

        # Thanh lọc (Filter bar)
        filters = FilterBar()
        search_input = filters.add_search("Tìm HĐ, đối tác...")
        filters.layout.addStretch()

        # — Bộ lọc năm —
        from PySide6.QtWidgets import QComboBox, QLabel as _QLabel, QFrame
        lbl_year = _QLabel("Năm:")
        combo_year = QComboBox()
        combo_year.addItems(years)
        combo_year.setCurrentText(str(current_year))
        combo_year.setFixedWidth(80)
        filters.layout.addWidget(lbl_year)
        filters.layout.addWidget(combo_year)

        btn_sync = GhostButton("Đồng bộ", _load_icon("dongbo_128.png"))
        btn_sync.clicked.connect(lambda: self._handle_sync(category))
        filters.layout.addWidget(btn_sync)

        btn_check_mail = GhostButton("Kiểm tra Mail", _load_icon("action_mail_128.png"))
        btn_check_mail.clicked.connect(lambda: self._on_check_mail_clicked(category))
        filters.layout.addWidget(btn_check_mail)
        self._btn_check_mail[category] = btn_check_mail
        btn_check_mail.setVisible(self._has_complete_mail_config())

        layout.addWidget(filters)

        # ── Mini dashboard thống kê ─────────────────────────────────────────
        stats_bar = QFrame()
        stats_bar.setObjectName("statsBar")
        stats_bar.setStyleSheet("""
            QFrame#statsBar {
                background: #f8fafc;
                border: 1px solid #e2e8f0;
                border-radius: 8px;
            }
        """)
        stats_layout = QHBoxLayout(stats_bar)
        stats_layout.setContentsMargins(8, 6, 8, 6)
        stats_layout.setSpacing(10)

        def _make_stat(label, color):
            box = QFrame()
            box.setStyleSheet("QFrame { background: white; border-radius: 6px; border: 1px solid #e2e8f0; }")
            box_l = QVBoxLayout(box)
            box_l.setContentsMargins(8, 4, 8, 4)
            box_l.setSpacing(2)
            cnt = _QLabel("0")
            cnt.setAlignment(Qt.AlignmentFlag.AlignCenter)
            cnt.setStyleSheet(f"font-size: 18px; font-weight: bold; color: {color}; border: none;")
            lbl = _QLabel(label)
            lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
            lbl.setStyleSheet("font-size: 11px; color: #64748b; font-weight: 500; border: none;")
            box_l.addWidget(cnt)
            box_l.addWidget(lbl)
            return box

        stat_total  = _make_stat("Tổng HĐ", "#0f172a")
        stat_signed = _make_stat("Đã ký",   "#16a34a")
        stat_pending= _make_stat("Chờ ký",  "#ea580c")
        stat_draft  = _make_stat("Nháp",    "#64748b")

        stats_layout.addWidget(stat_total)
        stats_layout.addWidget(stat_signed)
        stats_layout.addWidget(stat_pending)
        stats_layout.addWidget(stat_draft)
        layout.addWidget(stats_bar)

        # ── Bộ lọc trạng thái nhanh (Ngay trên Table) ───────────────────────
        status_bar = QWidget()
        status_layout = QHBoxLayout(status_bar)
        status_layout.setContentsMargins(0, 0, 0, 0)
        status_layout.setSpacing(8)

        status_btns = {}
        STATUSES = [("Tất cả", "all"), ("Nháp", "draft"),
                    ("Chờ ký", "pending"), ("Đã ký", "signed")]
        STATUS_COLORS = {
            "all":     ("#3b82f6", "#eff6ff"),
            "draft":   ("#64748b", "#f8fafc"),
            "pending": ("#ea580c", "#fff7ed"),
            "signed":  ("#16a34a", "#f0fdf4"),
        }

        def _make_status_btn(label, key):
            btn = QPushButton(label)
            bg, light_bg = STATUS_COLORS[key]
            # Height thấp gọn gàng, giống tab con
            btn.setStyleSheet(f"""
                QPushButton {{
                    background: {light_bg}; color: #64748b;
                    border: 1px solid #e2e8f0; border-radius: 4px;
                    padding: 4px 16px; font-size: 12px; font-weight: 600;
                    min-height: 24px; max-height: 24px;
                }}
                QPushButton:hover {{ background: #f1f5f9; color: #0f172a; }}
                QPushButton[active="true"] {{
                    background: {bg}; color: white; border: 1px solid {bg};
                }}
            """)
            btn.setProperty("active", "false")
            return btn

        def _set_active_status(active_key, reload=True):
            for k, b in status_btns.items():
                b.setProperty("active", "true" if k == active_key else "false")
                b.style().unpolish(b); b.style().polish(b)
            if reload:
                self._reload_table(category)

        for label, key in STATUSES:
            btn = _make_status_btn(label, key)
            status_btns[key] = btn
            status_layout.addWidget(btn)
            btn.clicked.connect(lambda _, k=key: _set_active_status(k))
            
        status_layout.addStretch()
        
        if not hasattr(self, "_tab_btns"):
            self._tab_btns = {}
        self._tab_btns[category] = {}

        _btn_refresh = GhostButton("Làm mới", icon=_load_icon("dongbo_128.png"))
        _btn_refresh.setToolTip("Kiểm tra trạng thái ký")
        _btn_refresh.setStyleSheet("padding: 4px 10px; min-height: 24px; max-height: 24px;")
        _btn_refresh.clicked.connect(lambda: self._on_load_status_clicked(category))
        self._tab_btns[category]["refresh"] = _btn_refresh

        _btn_import = GhostButton("↩️  Nhập vào")
        _btn_import.setToolTip("Nhập hợp đồng thủ công")
        _btn_import.setStyleSheet("border: 1px solid #f59e0b; padding: 4px 10px; min-height: 24px; max-height: 24px; color: #b45309; font-weight: bold;")
        _btn_import.clicked.connect(lambda: self._on_add_contract_click(category))
        self._tab_btns[category]["import"] = _btn_import

        _btn_download = GhostButton("Mở thư mục", icon=_load_icon("action_folder_128.png"))
        _btn_download.setToolTip("Mở thư mục lưu file hợp đồng đang chọn")
        _btn_download.setStyleSheet("border: 1px solid #3b82f6; padding: 4px 10px; min-height: 24px; max-height: 24px; color: #3b82f6; font-weight: bold;")
        _btn_download.clicked.connect(lambda: self._on_download_file_clicked(category))
        self._tab_btns[category]["download"] = _btn_download

        _btn_sign = GhostButton("Ký", icon=_load_icon("action_sign_128.png"))
        _btn_sign.setToolTip("Ký hóa đơn/hợp đồng")
        _btn_sign.setStyleSheet("border: 1px solid #16a34a; padding: 4px 10px; min-height: 24px; max-height: 24px; color: #16a34a; font-weight: bold;")
        _btn_sign.clicked.connect(lambda: self._on_sign_status_clicked(category))
        self._tab_btns[category]["sign"] = _btn_sign

        status_layout.addWidget(_btn_refresh)
        status_layout.addWidget(_btn_import)
        status_layout.addWidget(_btn_download)
        status_layout.addWidget(_btn_sign)

        layout.addWidget(status_bar)

        # Đặt mặc định Tất cả (nhưng không gọi reload table ngay lúc init)
        _set_active_status("all", reload=False)

        # ── Bảng dữ liệu (Table) ────────────────────────────────────────────
        table = QTableWidget(0, 8)
        if category == "sale":
            table.setHorizontalHeaderLabels([
                "☑", "Ngày ký", "Số HĐ", "MST Bên B", "MST Bên A", "Bên B ký", "Bên A ký", "Thao tác"
            ])
        else:
            table.setHorizontalHeaderLabels([
                "☑", "Ngày ký", "Số HĐ", "MST Bên A", "MST Bên B", "Bên A ký", "Bên B ký", "Thao tác"
            ])
        
        apply_table_style(table, stretch_col=-1)
        
        header = table.horizontalHeader()
        
        # Col 0: Checkbox (Select)
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
        table.setColumnWidth(0, 32)
        
        # Col 1: Ngày ký
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
        table.setColumnWidth(1, 95)
        
        # Col 2: Số HĐ co giãn
        header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
        
        # Col 3 & 4: MST
        header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
        table.setColumnWidth(3, 95)
        header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed)
        table.setColumnWidth(4, 95)
        
        # Col 5 & 6: Bên ký
        header.setSectionResizeMode(5, QHeaderView.ResizeMode.Fixed)
        table.setColumnWidth(5, 120)
        header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed)
        table.setColumnWidth(6, 120)
        
        # Col 7: Thao tác
        header.setSectionResizeMode(7, QHeaderView.ResizeMode.Fixed)
        table.setColumnWidth(7, 180)
        
        # Click header cột 0 → Chọn / Bỏ chọn tất cả
        def _toggle_all_checkboxes(logical_index):
            if logical_index == 0:
                all_checked = all(
                    table.item(r, 0) and table.item(r, 0).checkState() == Qt.CheckState.Checked
                    for r in range(table.rowCount())
                )
                new_state = Qt.CheckState.Unchecked if all_checked else Qt.CheckState.Checked
                for r in range(table.rowCount()):
                    if table.item(r, 0):
                        table.item(r, 0).setCheckState(new_state)
        header.sectionClicked.connect(_toggle_all_checkboxes)

        # Tick checkbox → tự động select dòng đó
        def _on_item_changed(item):
            if item and item.column() == 0:
                if item.checkState() == Qt.CheckState.Checked:
                    table.setCurrentCell(item.row(), 2)
        table.itemChanged.connect(_on_item_changed)

        # Ép chiều cao để grid "to ra nhiều dòng hơn"
        table.verticalHeader().setDefaultSectionSize(32)
        table.verticalHeader().setMinimumSectionSize(32)
        layout.addWidget(table)

        # ── Pagination Bar ────────────────────────────────────────────
        pag_bar = QWidget()
        pag_layout = QHBoxLayout(pag_bar)
        pag_layout.setContentsMargins(0, 5, 0, 0)
        
        btn_prev = GhostButton("Trang trước")
        btn_next = GhostButton("Trang sau")
        lbl_page = _QLabel("Trang 1 / 1")
        lbl_page.setAlignment(Qt.AlignmentFlag.AlignCenter)
        
        btn_prev.clicked.connect(lambda: self._go_page(category, -1))
        btn_next.clicked.connect(lambda: self._go_page(category, 1))
        
        pag_layout.addStretch()
        pag_layout.addWidget(btn_prev)
        pag_layout.addWidget(lbl_page)
        pag_layout.addWidget(btn_next)
        pag_layout.addStretch()
        
        layout.addWidget(pag_bar)

        # Lưu reference để reload
        if category == "sale":
            self.table_sale         = table
            self.combo_year_sale    = combo_year
            self.status_btns_sale   = status_btns
            self.stats_sale         = (stat_total, stat_signed, stat_pending, stat_draft)
            self.search_sale        = search_input
            self.page_lbl_sale      = lbl_page
            self.current_page_sale  = 1
        elif category == "purchase":
            self.table_purchase       = table
            self.combo_year_purchase  = combo_year
            self.status_btns_purchase = status_btns
            self.stats_purchase       = (stat_total, stat_signed, stat_pending, stat_draft)
            self.search_purchase      = search_input
            self.page_lbl_purchase    = lbl_page
            self.current_page_purchase = 1
        else:
            self.table_other          = table
            self.combo_year_other     = combo_year
            self.status_btns_other    = status_btns
            self.stats_other          = (stat_total, stat_signed, stat_pending, stat_draft)
            self.search_other         = search_input
            self.page_lbl_other       = lbl_page
            self.current_page_other   = 1

        # Kết nối events
        search_input.textChanged.connect(lambda: self._reload_table(category, reset_page=True))
        combo_year.currentTextChanged.connect(lambda: self._reload_table(category, reset_page=True))

        # Kích hoạt mặc định "Tất cả"
        QTimer.singleShot(0, lambda: status_btns["all"].setProperty("active", "true"))

        # Thực hiện load dữ liệu lần đầu
        QTimer.singleShot(500, lambda: self._reload_table(category))

        # layout.addStretch()  # Bỏ đi để table tự động giãn full grid xuống dưới
        return w

    def _build_other_contract_tab(self) -> QWidget:
        """Tab ký linh hoạt cho các mẫu hợp đồng không theo chuẩn Bên A/B."""
        w = QWidget()
        layout = QVBoxLayout(w)
        layout.setContentsMargins(16, 16, 16, 16)
        layout.setSpacing(12)

        title = QLabel("Ký hợp đồng khác (tùy chọn vị trí)")
        title.setStyleSheet("font-size: 16px; font-weight: 700; color: #0f172a;")
        layout.addWidget(title)

        hint = QLabel(
            "Dùng cho hợp đồng lao động, thuê nhà, dịch vụ, vay tiền, thi công, đại lý...\n"
            "Có thể nạp nhanh file từ dòng đã chọn ở tab Hợp đồng khác, rồi chỉnh vị trí ký theo preset/offset."
        )
        hint.setStyleSheet("color: #475569; font-size: 12px;")
        layout.addWidget(hint)

        quick_row = QHBoxLayout()
        self.btn_other_from_selected = SecondaryButton("Nạp từ dòng đã chọn ở Hợp đồng khác", icon=_load_icon("action_view_128.png"))
        self.btn_other_from_selected.clicked.connect(self._fill_other_from_selected_contract)
        quick_row.addWidget(self.btn_other_from_selected)
        quick_row.addStretch()
        layout.addLayout(quick_row)

        box = QFrame()
        box.setStyleSheet("QFrame { background: #ffffff; border: 1px solid #e2e8f0; border-radius: 10px; }")
        form = QGridLayout(box)
        form.setContentsMargins(14, 14, 14, 14)
        form.setHorizontalSpacing(10)
        form.setVerticalSpacing(10)

        form.addWidget(QLabel("PDF đầu vào:"), 0, 0)
        self.other_input_pdf = QLineEdit()
        self.other_input_pdf.setPlaceholderText("Chọn file PDF cần ký...")
        form.addWidget(self.other_input_pdf, 0, 1)
        btn_pick_in = GhostButton("Chọn file")
        btn_pick_in.clicked.connect(self._pick_other_input_pdf)
        form.addWidget(btn_pick_in, 0, 2)

        form.addWidget(QLabel("PDF đầu ra:"), 1, 0)
        self.other_output_pdf = QLineEdit()
        self.other_output_pdf.setPlaceholderText("Đường dẫn file đã ký...")
        form.addWidget(self.other_output_pdf, 1, 1)
        btn_pick_out = GhostButton("Lưu tại")
        btn_pick_out.clicked.connect(self._pick_other_output_pdf)
        form.addWidget(btn_pick_out, 1, 2)

        form.addWidget(QLabel("Từ khóa neo:"), 2, 0)
        self.other_keyword = QComboBox()
        self.other_keyword.setEditable(True)
        self.other_keyword.addItems([
            "ĐẠI DIỆN BÊN A",
            "ĐẠI DIỆN BÊN B",
            "ĐẠI DIỆN BÊN C",
            "BÊN A",
            "BÊN B",
            "BÊN C",
            "BÊN BÁN",
            "BÊN MUA",
            "NGƯỜI SỬ DỤNG LAO ĐỘNG",
            "NGƯỜI LAO ĐỘNG",
            "BÊN CHO THUÊ",
            "BÊN THUÊ",
            "BÊN CUNG CẤP DỊCH VỤ",
            "BÊN SỬ DỤNG DỊCH VỤ",
            "BÊN CHO VAY",
            "BÊN VAY",
            "CHỦ ĐẦU TƯ",
            "NHÀ THẦU",
            "BÊN GIAO ĐẠI LÝ",
            "BÊN ĐẠI LÝ",
        ])
        self.other_keyword.setCurrentText("ĐẠI DIỆN BÊN B")
        self.other_keyword.setToolTip("Danh sách nhãn chữ ký phổ biến tại Việt Nam. Có thể nhập tay từ khóa khác nếu mẫu dùng nhãn riêng.")
        form.addWidget(self.other_keyword, 2, 1)
        self.btn_add_other_keyword = GhostButton("Thêm từ khóa")
        self.btn_add_other_keyword.clicked.connect(self._add_other_keyword)
        form.addWidget(self.btn_add_other_keyword, 2, 2)

        self._load_other_custom_keywords()

        form.addWidget(QLabel("Offset X:"), 3, 0)
        self.other_offset_x = QLineEdit("-45")
        self.other_offset_x.setPlaceholderText("px")
        form.addWidget(self.other_offset_x, 3, 1)

        form.addWidget(QLabel("Offset Y:"), 3, 2)
        self.other_offset_y = QLineEdit("0")
        self.other_offset_y.setPlaceholderText("px")
        form.addWidget(self.other_offset_y, 3, 3)

        form.addWidget(QLabel("Preset vị trí:"), 4, 0)
        self.other_offset_preset = QComboBox()
        self.other_offset_preset.addItems([
            "Mặc định (canh giữa, ngay dưới từ khóa)",
            "Tự nhập offset",
        ])
        self.other_offset_preset.setCurrentIndex(0)
        self.other_offset_preset.currentTextChanged.connect(self._apply_other_offset_preset)
        form.addWidget(self.other_offset_preset, 4, 1, 1, 3)

        self.chk_other_auto_import = QCheckBox("Tự cập nhật vào DB Hợp đồng khác sau khi ký")
        self.chk_other_auto_import.setChecked(True)
        form.addWidget(self.chk_other_auto_import, 5, 0, 1, 4)

        layout.addWidget(box)

        action_row = QHBoxLayout()
        action_row.addStretch()
        self.btn_sign_other = PrimaryButton("Ký file theo vị trí tùy chọn", icon=_load_icon("action_sign_128.png"))
        self.btn_sign_other.setFixedHeight(44)
        self.btn_sign_other.clicked.connect(self._on_sign_other_contract)
        action_row.addWidget(self.btn_sign_other)

        self.btn_open_other_output = GhostButton("Mở file đầu ra", icon=_load_icon("action_folder_128.png"))
        self.btn_open_other_output.clicked.connect(self._open_other_signed_file)
        action_row.addWidget(self.btn_open_other_output)
        action_row.addStretch()

        layout.addLayout(action_row)
        layout.addStretch()
        return w

    def _pick_other_input_pdf(self):
        path, _ = QFileDialog.getOpenFileName(self.window(), "Chọn file PDF", "", "PDF Files (*.pdf)")
        if not path:
            return
        self.other_input_pdf.setText(path)
        if not self.other_output_pdf.text().strip():
            base, _ = os.path.splitext(path)
            self.other_output_pdf.setText(f"{base}_signed.pdf")

    def _pick_other_output_pdf(self):
        default_name = self.other_output_pdf.text().strip() or "contract_signed.pdf"
        path, _ = QFileDialog.getSaveFileName(self.window(), "Lưu file đã ký", default_name, "PDF Files (*.pdf)")
        if path:
            self.other_output_pdf.setText(path)

    def _open_pdf_in_pdf_tools(self, pdf_path: str) -> bool:
        """Open a contract PDF inside the app's PDF tools tab when available."""
        import os

        if not pdf_path or not os.path.exists(pdf_path):
            return False

        main_window = self.window()
        pages = getattr(main_window, "pages", {}) if main_window is not None else {}
        pdf_page = pages.get("pdf_tools") if isinstance(pages, dict) else None
        if pdf_page is not None and hasattr(pdf_page, "load_pdf"):
            pdf_page.load_pdf(pdf_path)
            if hasattr(main_window, "switch_page"):
                main_window.switch_page("pdf_tools")
            return True

        os.startfile(pdf_path)
        return True

    def _open_other_signed_file(self):
        out_path = self.other_output_pdf.text().strip()
        if out_path and os.path.exists(out_path):
            self._open_pdf_in_pdf_tools(out_path)
        else:
            QMessageBox.information(self.window(), "Thông báo", "Chưa có file đầu ra hoặc file không tồn tại.")

    def _apply_other_offset_preset(self, preset: str):
        if preset == "Mặc định (canh giữa, ngay dưới từ khóa)":
            self.other_offset_x.setText("-45")
            self.other_offset_y.setText("0")

    def _fill_other_from_selected_contract(self):
        table = getattr(self, "table_other", None)
        if table is None:
            QMessageBox.information(self.window(), "Thông báo", "Tab Hợp đồng khác chưa sẵn sàng.")
            return
        curr = table.currentRow()
        if curr < 0:
            QMessageBox.information(self.window(), "Chưa chọn hợp đồng", "Vui lòng chọn 1 dòng ở tab Hợp đồng khác trước.")
            return

        item = table.item(curr, 2)
        if not item:
            QMessageBox.warning(self.window(), "Lỗi", "Không đọc được mã hợp đồng ở dòng đang chọn.")
            return

        c_name = item.text().strip()
        if not c_name:
            QMessageBox.warning(self.window(), "Lỗi", "Mã hợp đồng trống.")
            return

        try:
            from core.contracts_db import get_connection
            with get_connection() as conn:
                row = conn.execute(
                    "SELECT path FROM contracts_other WHERE name_file = ? ORDER BY modified_at DESC LIMIT 1",
                    (c_name,),
                ).fetchone()

            in_path = ""
            if row and row["path"]:
                candidate = get_company_root_path(row["path"].replace("/", os.sep))
                if os.path.exists(candidate) and candidate.lower().endswith(".pdf"):
                    in_path = candidate

            if not in_path:
                base_dir = get_company_root_path(os.path.join("contracts", "contracts_other", "data"))
                found = []
                if os.path.isdir(base_dir):
                    for d in os.listdir(base_dir):
                        d_path = os.path.join(base_dir, d)
                        if not os.path.isdir(d_path):
                            continue
                        for sub in ("completed", "pending", "draft"):
                            s_path = os.path.join(d_path, sub)
                            if not os.path.isdir(s_path):
                                continue
                            for fn in os.listdir(s_path):
                                if fn.startswith(c_name) and fn.lower().endswith(".pdf"):
                                    pri = 0 if sub == "completed" else 1 if sub == "pending" else 2
                                    found.append((pri, -len(fn), os.path.join(s_path, fn)))
                if found:
                    found.sort()
                    in_path = found[0][2]

            if not in_path:
                QMessageBox.warning(self.window(), "Không thấy file PDF", f"Không tìm thấy file PDF cho hợp đồng {c_name}.")
                return

            self.other_input_pdf.setText(in_path)
            base, _ = os.path.splitext(in_path)
            self.other_output_pdf.setText(f"{base}_signed.pdf")
            self.other_keyword.setCurrentText("ĐẠI DIỆN BÊN B")
            self.other_offset_preset.setCurrentText("Mặc định (dưới từ khóa, canh giữa)")
            self.tabs.setCurrentWidget(self.other_contract_tab)
            show_toast(self.window(), f"✅ Đã nạp file ký cho {c_name}")
        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi", f"Không nạp được dữ liệu ký: {e}")

    def _auto_update_other_db_after_sign(self, input_pdf: str, output_pdf: str) -> None:
        if not self.chk_other_auto_import.isChecked():
            return
        try:
            import shutil
            from core.contracts_db import scan_contract_files

            norm_in = os.path.normpath(input_pdf)
            marker = os.path.normpath(os.path.join("contracts", "contracts_other", "data"))
            if marker not in norm_in:
                return

            rel = norm_in.split(marker + os.sep, 1)[-1]
            parts = rel.split(os.sep)
            if len(parts) < 3:
                return
            date_part = parts[0]

            base_name = os.path.splitext(os.path.basename(output_pdf))[0]
            c_name = re.sub(r'(?i)[_ ](aky|bky|asign|bsign|signed?)([_ ].*)?$', '', base_name).strip("_ ")
            if not c_name:
                c_name = base_name

            low = base_name.lower()
            has_a = "_aky" in low or "_asign" in low
            has_b = "_bky" in low or "_bsign" in low
            if has_a and has_b:
                idx_a = low.find("_aky")
                idx_b = low.find("_bky")
                if idx_a != -1 and idx_b != -1 and idx_b < idx_a:
                    seq_suffix = "_bky_aky"
                else:
                    seq_suffix = "_aky_bky"
                folder = "completed"
            elif has_a:
                seq_suffix = "_aky"
                folder = "pending"
            elif has_b:
                seq_suffix = "_bky"
                folder = "pending"
            else:
                seq_suffix = ""
                folder = "pending"

            dest_dir = get_company_root_path(os.path.join("contracts", "contracts_other", "data", date_part, folder))
            os.makedirs(dest_dir, exist_ok=True)
            dest_path = os.path.join(dest_dir, f"{c_name}{seq_suffix}.pdf")
            shutil.copy2(output_pdf, dest_path)

            scan_contract_files("other", full_refresh=True)
            self._reload_table("other")
        except Exception:
            pass

    def _open_other_flexible_sign_dialog_from_list(self, c_name: str, c_path: str = "",
                                                     current_seq: str = "", c_date: str = ""):
        """Open a quick flexible-sign dialog for contracts_other from the list tab."""
        from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QComboBox, QMessageBox, QCheckBox
        from core.contracts_db import own_sign_sequence, filename_from_sequence

        # -- Resolve input PDF from DB path or filesystem scan --
        def _resolve_default_input() -> str:
            if c_path:
                candidate = get_company_root_path(c_path.replace("/", os.sep))
                if os.path.exists(candidate) and candidate.lower().endswith(".pdf"):
                    return candidate

            base_dir = get_company_root_path(os.path.join("contracts", "contracts_other", "data"))
            found = []
            if os.path.isdir(base_dir):
                for d in os.listdir(base_dir):
                    d_path = os.path.join(base_dir, d)
                    if not os.path.isdir(d_path):
                        continue
                    for sub in ("completed", "pending", "draft"):
                        s_path = os.path.join(d_path, sub)
                        if not os.path.isdir(s_path):
                            continue
                        for fn in os.listdir(s_path):
                            if fn.startswith(c_name) and fn.lower().endswith(".pdf"):
                                pri = 0 if sub == "completed" else 1 if sub == "pending" else 2
                                found.append((pri, -len(fn), os.path.join(s_path, fn)))
            if found:
                found.sort()
                return found[0][2]
            return ""

        input_pdf = _resolve_default_input()

        # If no PDF found, let user pick one before showing dialog
        if not input_pdf:
            p, _ = QFileDialog.getOpenFileName(
                self.window(), f"Chọn file PDF để ký — {c_name}", "", "PDF Files (*.pdf)"
            )
            if not p:
                return
            input_pdf = p

        # Compute output path following _bky/_aky/_cky/... convention
        def _make_output_path(party: str) -> str:
            parts = [p.strip() for p in current_seq.split(",") if p.strip()] if current_seq else []
            if party not in parts:
                parts.append(party)
            new_seq = ",".join(parts)
            new_fname = filename_from_sequence(c_name, new_seq)
            all_parties = set(parts)
            # "completed" khi đã có cả bên A lẫn bên B (bên C tuỳ chọn)
            is_fully = "a" in all_parties and "b" in all_parties
            folder = "completed" if is_fully else "pending"
            date_part = c_date or __import__("datetime").datetime.now().strftime("%Y-%m-%d")
            out_dir = get_company_root_path(os.path.join("contracts", "contracts_other", "data", date_part, folder))
            os.makedirs(out_dir, exist_ok=True)
            return os.path.join(out_dir, new_fname)

        def _party_from_keyword(kw: str) -> str:
            kw_upper = kw.upper()
            if "BÊN A" in kw_upper or "BEN A" in kw_upper:
                return "a"
            if "BÊN C" in kw_upper or "BEN C" in kw_upper:
                return "c"
            return "b"

        dlg = QDialog(self.window())
        dlg.setWindowTitle(f"Ký hợp đồng — {c_name}")
        dlg.resize(560, 200)
        dlg.setStyleSheet("""
            QDialog {
                background: #f8fafc;
            }
            QLabel {
                color: #0f172a;
                background: transparent;
            }
            QComboBox, QLineEdit {
                background: #ffffff;
                border: 1px solid #cbd5e1;
                border-radius: 6px;
                padding: 4px 8px;
                color: #0f172a;
            }
            QComboBox::drop-down {
                border: none;
                width: 22px;
            }
        """)
        lay = QVBoxLayout(dlg)
        lay.setContentsMargins(14, 14, 14, 14)
        lay.setSpacing(10)

        lay.addWidget(QLabel(f"<b>Hợp đồng:</b> {c_name}"))

        # Row: keyword
        row_kw = QHBoxLayout()
        row_kw.addWidget(QLabel("Từ khóa neo:"))
        cmb_kw = QComboBox()
        cmb_kw.setEditable(True)
        cmb_kw.setMinimumWidth(340)
        for i in range(self.other_keyword.count()):
            cmb_kw.addItem(self.other_keyword.itemText(i))
        cmb_kw.setCurrentText(self.other_keyword.currentText().strip() or "ĐẠI DIỆN BÊN B")
        row_kw.addWidget(cmb_kw, 1)
        btn_find_kw = QPushButton("Tìm từ khóa")
        row_kw.addWidget(btn_find_kw)
        lay.addLayout(row_kw)

        def _find_keyword_anchor():
            if not input_pdf or not os.path.exists(input_pdf):
                QMessageBox.warning(dlg, "Thiếu file", "Không tìm thấy file PDF để tìm từ khóa.")
                return
            try:
                import sys
                if getattr(sys, "frozen", False):
                    sign_dir = os.path.join(sys._MEIPASS, "sign")
                else:
                    sign_dir = os.path.normpath(os.path.join(
                        os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "sign"
                    ))
                    if not os.path.isdir(sign_dir):
                        from core.drive_sync import _ROOT
                        sign_dir = os.path.normpath(os.path.join(_ROOT, "..", "sign"))

                if not os.path.isdir(sign_dir):
                    QMessageBox.warning(dlg, "Lỗi", f"Không tìm thấy thư mục ký số: {sign_dir}")
                    return
                if sign_dir not in sys.path:
                    sys.path.insert(0, sign_dir)

                from sign_pdf import find_keyword_position

                # Quét toàn bộ danh sách keyword hiện có trong combobox.
                candidates = []
                seen = set()
                for i in range(cmb_kw.count()):
                    kw_i = cmb_kw.itemText(i).strip()
                    if not kw_i:
                        continue
                    key_norm = kw_i.lower()
                    if key_norm in seen:
                        continue
                    seen.add(key_norm)
                    candidates.append(kw_i)

                current_kw = cmb_kw.currentText().strip()
                if current_kw and current_kw.lower() not in seen:
                    candidates.insert(0, current_kw)

                if not candidates:
                    QMessageBox.information(dlg, "Thiếu từ khóa", "Không có từ khóa nào để tìm.")
                    return

                found_keywords = []
                missing_keywords = []
                first_pos = None
                for kw in candidates:
                    pos = find_keyword_position(input_pdf, kw)
                    if pos:
                        found_keywords.append(kw)
                        if first_pos is None:
                            first_pos = (kw, pos)
                    else:
                        missing_keywords.append(kw)

                if not found_keywords:
                    QMessageBox.warning(
                        dlg,
                        "Không tìm thấy",
                        "Không tìm thấy từ khóa nào trong hợp đồng.\n"
                        "Vui lòng nhập thêm từ khóa khác hoặc kiểm tra nội dung PDF.",
                    )
                    return

                # Lọc lại combobox: chỉ giữ keyword có tồn tại trong hợp đồng.
                cmb_kw.clear()
                cmb_kw.addItems(found_keywords)
                cmb_kw.setCurrentText(found_keywords[0])

                hit_kw, hit_pos = first_pos
                x0, x1, y, page_num = hit_pos
                msg = (
                    f"Đã giữ lại {len(found_keywords)} từ khóa có trong hợp đồng, "
                    f"loại {len(missing_keywords)} từ khóa không có.\n\n"
                    f"Từ khóa đầu tiên tìm thấy: '{hit_kw}' (trang {page_num + 1})\n"
                    f"X=[{x0:.1f},{x1:.1f}] | Y={y:.1f}"
                )
                if missing_keywords:
                    preview = ", ".join(missing_keywords[:5])
                    if len(missing_keywords) > 5:
                        preview += ", ..."
                    msg += f"\n\nĐã loại: {preview}"

                QMessageBox.information(dlg, "Lọc từ khóa hoàn tất", msg)
            except Exception as e:
                QMessageBox.warning(dlg, "Lỗi tìm từ khóa", str(e))

        btn_find_kw.clicked.connect(_find_keyword_anchor)

        # Row: preset + X/Y offsets
        row_pos = QHBoxLayout()
        row_pos.addWidget(QLabel("Vị trí ký:"))
        cmb_preset = QComboBox()
        cmb_preset.addItems([
            "Mặc định (canh giữa, ngay dưới từ khóa)",
            "Tự nhập offset",
        ])
        cmb_preset.setCurrentText("Mặc định (canh giữa, ngay dưới từ khóa)")
        row_pos.addWidget(cmb_preset)
        row_pos.addWidget(QLabel("X:"))
        txt_x = QLineEdit(self.other_offset_x.text().strip() or "-45")
        txt_x.setFixedWidth(55)
        row_pos.addWidget(txt_x)
        row_pos.addWidget(QLabel("Y:"))
        txt_y = QLineEdit(self.other_offset_y.text().strip() or "0")
        txt_y.setFixedWidth(55)
        row_pos.addWidget(txt_y)
        row_pos.addStretch()
        lay.addLayout(row_pos)

        def _apply_preset(preset: str):
            if preset == "Mặc định (canh giữa, ngay dưới từ khóa)":
                txt_x.setText("-45"); txt_y.setText("0")
        cmb_preset.currentTextChanged.connect(_apply_preset)

        row_btn = QHBoxLayout()
        row_btn.addStretch()
        btn_cancel = QPushButton("Hủy")
        btn_ok = QPushButton("Ký ngay")
        btn_ok.setStyleSheet("background:#16a34a;color:white;font-weight:bold;padding:6px 12px;border-radius:4px;")
        row_btn.addWidget(btn_cancel)
        row_btn.addWidget(btn_ok)
        lay.addLayout(row_btn)

        btn_cancel.clicked.connect(dlg.reject)

        def _accept_and_sign():
            selected_party = _party_from_keyword(cmb_kw.currentText().strip())
            out_path = _make_output_path(selected_party)
            self.other_input_pdf.setText(input_pdf)
            self.other_output_pdf.setText(out_path)
            self.other_keyword.setCurrentText(cmb_kw.currentText().strip())
            self.other_offset_x.setText(txt_x.text().strip() or "-45")
            self.other_offset_y.setText(txt_y.text().strip() or "0")
            dlg.accept()
            self._on_sign_other_contract()

        btn_ok.clicked.connect(_accept_and_sign)
        dlg.exec()

    def _add_other_keyword(self):
        kw = self.other_keyword.currentText().strip()
        if not kw:
            QMessageBox.information(self.window(), "Thiếu từ khóa", "Vui lòng nhập từ khóa trước khi thêm.")
            return

        exists = any(self.other_keyword.itemText(i).strip().lower() == kw.lower() for i in range(self.other_keyword.count()))
        if not exists:
            self.other_keyword.addItem(kw)
        self.other_keyword.setCurrentText(kw)
        self._save_other_custom_keywords()
        show_toast(self.window(), f"✅ Đã thêm từ khóa: {kw}")

    def _load_other_custom_keywords(self):
        try:
            from core.utils import get_app_data_path
            settings_path = get_app_data_path("settings.json")
            if not os.path.exists(settings_path):
                return
            with open(settings_path, "r", encoding="utf-8") as f:
                cfg = json.load(f)
            extra = cfg.get("company", {}).get("custom_signature_keywords", [])
            if not isinstance(extra, list):
                return
            for kw in extra:
                kw_str = str(kw).strip()
                if not kw_str:
                    continue
                exists = any(self.other_keyword.itemText(i).strip().lower() == kw_str.lower() for i in range(self.other_keyword.count()))
                if not exists:
                    self.other_keyword.addItem(kw_str)
        except Exception:
            pass

    def _save_other_custom_keywords(self):
        try:
            from core.utils import get_app_data_path
            settings_path = get_app_data_path("settings.json")
            cfg = {}
            if os.path.exists(settings_path):
                with open(settings_path, "r", encoding="utf-8") as f:
                    cfg = json.load(f)

            company = cfg.setdefault("company", {})
            default_keywords = {
                "ĐẠI DIỆN BÊN A", "ĐẠI DIỆN BÊN B", "BÊN A", "BÊN B", "BÊN BÁN", "BÊN MUA",
                "NGƯỜI SỬ DỤNG LAO ĐỘNG", "NGƯỜI LAO ĐỘNG", "BÊN CHO THUÊ", "BÊN THUÊ",
                "BÊN CUNG CẤP DỊCH VỤ", "BÊN SỬ DỤNG DỊCH VỤ", "BÊN CHO VAY", "BÊN VAY",
                "CHỦ ĐẦU TƯ", "NHÀ THẦU", "BÊN GIAO ĐẠI LÝ", "BÊN ĐẠI LÝ",
            }
            custom_list = []
            for i in range(self.other_keyword.count()):
                kw = self.other_keyword.itemText(i).strip()
                if not kw:
                    continue
                if kw in default_keywords:
                    continue
                if kw not in custom_list:
                    custom_list.append(kw)

            company["custom_signature_keywords"] = custom_list
            with open(settings_path, "w", encoding="utf-8") as f:
                json.dump(cfg, f, ensure_ascii=False, indent=2)
        except Exception:
            pass

    def _on_sign_other_contract(self):
        input_pdf = self.other_input_pdf.text().strip()
        output_pdf = self.other_output_pdf.text().strip()
        keyword = self.other_keyword.currentText().strip() or "ĐẠI DIỆN BÊN B"

        if not input_pdf or not os.path.exists(input_pdf):
            QMessageBox.warning(self.window(), "Thiếu file", "Vui lòng chọn file PDF đầu vào hợp lệ.")
            return
        if not output_pdf:
            QMessageBox.warning(self.window(), "Thiếu đầu ra", "Vui lòng chọn đường dẫn lưu file đã ký.")
            return

        try:
            x_off = int((self.other_offset_x.text() or "0").strip())
            y_off = int((self.other_offset_y.text() or "0").strip())
        except Exception:
            QMessageBox.warning(self.window(), "Sai định dạng", "Offset X/Y phải là số nguyên.")
            return

        try:
            if getattr(sys, "frozen", False):
                sign_dir = os.path.join(sys._MEIPASS, "sign")
            else:
                sign_dir = os.path.normpath(os.path.join(
                    os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "sign"
                ))
                if not os.path.isdir(sign_dir):
                    from core.drive_sync import _ROOT
                    sign_dir = os.path.normpath(os.path.join(_ROOT, "..", "sign"))

            if not os.path.isdir(sign_dir):
                QMessageBox.critical(self.window(), "Lỗi", f"Không tìm thấy thư mục ký số: {sign_dir}")
                return
            if sign_dir not in sys.path:
                sys.path.insert(0, sign_dir)

            ts_url = "http://timestamp.digicert.com"
            yaml_path = os.path.join(sign_dir, "sign.yaml")
            try:
                import yaml
                with open(yaml_path, "r", encoding="utf-8") as yf:
                    cfg = yaml.safe_load(yf)
                ts_cfg = cfg.get("signing", {}).get("timestamp", {})
                ts_url = ts_cfg.get("url", ts_url) if ts_cfg.get("enabled", True) else None
            except Exception:
                pass

            from win_signer import select_signer
            from sign_pdf import PDFSigner

            signer_obj = select_signer()
            if not signer_obj:
                show_toast(self.window(), "⚠️ Đã hủy chọn chứng chỉ.")
                return

            show_toast(self.window(), f"⏳ Đang ký theo từ khóa '{keyword}'...")
            self.btn_sign_other.setEnabled(False)
            try:
                pdf_engine = PDFSigner(None, None)
                pdf_engine.sign_pdf_native(
                    in_file=input_pdf,
                    out_file=output_pdf,
                    keyword=keyword,
                    signer=signer_obj,
                    offsets=(x_off, y_off),
                    ts_url=ts_url,
                    use_logo=True,
                )
            finally:
                self.btn_sign_other.setEnabled(True)

            if os.path.exists(output_pdf):
                show_toast(self.window(), f"✅ Ký thành công: {output_pdf}")
                self._auto_update_other_db_after_sign(input_pdf, output_pdf)
                if QMessageBox.question(self.window(), "Mở file", "Ký xong. Bạn có muốn mở file đã ký?") == QMessageBox.StandardButton.Yes:
                    self._open_pdf_in_pdf_tools(output_pdf)
            else:
                QMessageBox.warning(self.window(), "Cảnh báo", "Đã chạy ký nhưng chưa thấy file đầu ra.")

        except ImportError as ie:
            QMessageBox.critical(self.window(), "Lỗi import module ký", f"{ie}\nVui lòng cài dependencies trong sign/requirements.txt")
        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi ký hợp đồng khác", str(e))

    def _build_contract_tab(self) -> QWidget:
        w = QWidget()
        outer = QVBoxLayout(w)
        outer.setContentsMargins(0, 0, 0, 0)

        scroll = QScrollArea()
        scroll.setWidgetResizable(True)
        scroll.setFrameShape(QFrame.Shape.NoFrame)

        body = QWidget()
        body_layout = QVBoxLayout(body)
        body_layout.setContentsMargins(30, 25, 30, 25)
        body_layout.setSpacing(20)

        contract_tabs = QTabWidget()
        contract_tabs.setDocumentMode(True)
        body_layout.addWidget(contract_tabs)

        compose_tab = QWidget()
        compose_layout = QVBoxLayout(compose_tab)
        compose_layout.setContentsMargins(0, 0, 0, 0)
        compose_layout.setSpacing(20)
        contract_tabs.addTab(compose_tab, "Soạn hợp đồng")

        template_manage_tab = QWidget()
        template_layout = QVBoxLayout(template_manage_tab)
        template_layout.setContentsMargins(0, 0, 0, 0)
        template_layout.setSpacing(16)
        contract_tabs.addTab(template_manage_tab, "Quản lý mẫu hợp đồng")

        product_manage_tab = QWidget()
        product_layout = QVBoxLayout(product_manage_tab)
        product_layout.setContentsMargins(0, 0, 0, 0)
        product_layout.setSpacing(16)
        contract_tabs.addTab(product_manage_tab, "Quản lý sản phẩm")

        # ── Section 1: Thông tin chung ──────────────────────
        info_box = make_group("Thông tin chung hợp đồng")
        form_layout = make_form()
        info_box.layout().addLayout(form_layout)

        self.combo_contract_type = make_combo(["Hợp đồng Bán Hàng (Đầu ra)", "Hợp đồng Mua Hàng (Đầu vào)"])
        self.input_code = make_input("Nhập mã hợp đồng...")
        
        self.input_date = QDateEdit()
        self.input_date.setCalendarPopup(True)
        self.input_date.setDisplayFormat("dd/MM/yyyy")
        self.input_date.setDate(QDate.currentDate())
        self.input_date.setMinimumHeight(36)
        self.input_date.setStyleSheet(
            "QDateEdit { padding: 0 8px; border: 1px solid #d1d5db; border-radius: 6px; background: white; }"
            "QDateEdit:focus { border: 1px solid #2563eb; }"
        )
        
        self.btn_refresh_code = IconButton(_load_icon("action_next_128.png"), tooltip="Tạo mã ngẫu nhiên")
        self.btn_refresh_code.clicked.connect(self._refresh_contract_id)
        
        row_id_date = QHBoxLayout()
        row_id_date.addWidget(self.input_code, stretch=1)
        row_id_date.addWidget(self.btn_refresh_code)
        row_id_date.addWidget(QLabel("   Ngày ký:"))
        row_id_date.addWidget(self.input_date, stretch=1)

        self.input_client = make_input("Nhập tên hoặc mã nội bộ khách hàng (Bên A)...")
        self._partners_cache: list[dict] = []  # cache danh sách partners

        # Completer tìm kiếm theo tên + mã
        self._client_completer = QCompleter([], self.input_client)
        self._client_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
        self._client_completer.setFilterMode(Qt.MatchFlag.MatchContains)
        self._client_completer.activated.connect(self._on_client_selected)
        self.input_client.setCompleter(self._client_completer)

        btn_pick_customer = SecondaryButton("👥 Chọn KH", icon=_load_icon("action_search_128.png"))
        btn_pick_customer.setFixedWidth(130)
        btn_pick_customer.clicked.connect(self._on_pick_customer_dialog)

        self.btn_lookup_mst = SecondaryButton("Tìm MST", icon=_load_icon("action_search_128.png"))
        self.btn_lookup_mst.clicked.connect(self._on_lookup_mst)

        row_client = QHBoxLayout()
        row_client.addWidget(self.input_client, stretch=1)
        row_client.addWidget(btn_pick_customer)

        self._refresh_contract_id()
        
        self.input_address = make_input("Nhập địa chỉ...")
        
        # MST & Phone row
        row_phone_mst = QHBoxLayout()
        self.input_mst = make_input("Mã số thuế...")
        self.input_phone = make_input("Số điện thoại...")
        row_phone_mst.addWidget(self.input_mst, stretch=1)
        row_phone_mst.addWidget(QLabel("   Điện thoại:"))
        row_phone_mst.addWidget(self.input_phone, stretch=1)
        
        # Rep & Position row
        row_rep = QHBoxLayout()
        self.input_rep = make_input("Người đại diện...")
        self.input_position = make_input("Chức vụ...")
        self.input_position.setText("Giám đốc")
        row_rep.addWidget(self.input_rep, stretch=2)
        row_rep.addWidget(QLabel("   Chức vụ:"))
        row_rep.addWidget(self.input_position, stretch=1)

        # Placeholders giữ tương thích với _on_generate (fax/account vẫn tồn tại ngầm)
        self.input_fax = make_input("")
        self.input_fax.hide()
        self.input_account = make_input("")
        self.input_account.hide()

        self.input_total_amount = make_input("0")
        self.input_total_amount.setReadOnly(True)
        self.input_total_amount.setStyleSheet("background-color: #f8f9fa; font-weight: bold;")
        
        self.input_total_word = make_input("Tự động tính bằng chữ...")
        self.input_total_word.setReadOnly(True)
        self.input_total_word.setStyleSheet("background-color: #f8f9fa;")

        form_layout.addRow("Mã HĐ / Ngày:", row_id_date)
        form_layout.addRow("Bên mua (Bên A):", row_client)
        form_layout.addRow("Địa chỉ:", self.input_address)
        form_layout.addRow("MST:", row_phone_mst)
        form_layout.addRow("Đại diện:", row_rep)

        compose_layout.addWidget(info_box)

        # ── Section 2: Danh mục Sản phẩm & Mẫu Hợp Đồng ────────
        source_box = make_group("Chọn mẫu và dữ liệu sản phẩm")
        source_layout = QGridLayout()
        source_layout.setSpacing(10)
        source_box.layout().addLayout(source_layout)

        source_layout.addWidget(QLabel("Mẫu Hợp Đồng:"), 0, 0)
        self.combo_template = make_combo(self._get_templates())
        source_layout.addWidget(self.combo_template, 0, 1)

        source_layout.addWidget(QLabel("File Excel Sản Phẩm:"), 1, 0)
        self.input_excel_path = make_input(self.default_product_catalog_path)
        source_layout.addWidget(self.input_excel_path, 1, 1)
        
        self.btn_load_excel = SecondaryButton("Nạp Sản phẩm", icon=_load_icon("action_receipt_128.png"))
        self.btn_load_excel.clicked.connect(self._on_load_excel)
        source_layout.addWidget(self.btn_load_excel, 1, 2)

        source_layout.addWidget(QLabel("Link Google Sheets:"), 2, 0)
        self.input_gs_link = make_input("Dán link Google Sheets tại đây...")
        source_layout.addWidget(self.input_gs_link, 2, 1)

        # Buttons with icons
        self.btn_load_gs = SecondaryButton("Nạp từ Sheets", icon=_load_icon("google sheet_128.png"))
        self.btn_load_gs.clicked.connect(self._on_load_gs)
        source_layout.addWidget(self.btn_load_gs, 2, 2)

        compose_layout.addWidget(source_box)

        template_manage_box = make_group("Quản lý mẫu hợp đồng")
        template_manage_layout = QGridLayout()
        template_manage_layout.setSpacing(10)
        template_manage_box.layout().addLayout(template_manage_layout)

        template_note = QLabel(
            "Thêm hoặc chỉnh sửa file .docx trong thư mục dưới đây. Sau khi thay đổi, bấm làm mới để danh sách mẫu cập nhật ngay trong tab Soạn hợp đồng."
        )
        template_note.setWordWrap(True)
        template_note.setStyleSheet(f"color: {COLORS['text_secondary']};")
        template_manage_layout.addWidget(template_note, 0, 0, 1, 3)

        template_manage_layout.addWidget(QLabel("Thư mục mẫu hợp đồng:"), 1, 0)
        self.input_contract_folder = make_input(self.contract_dir)
        self.input_contract_folder.setReadOnly(True)
        self.input_contract_folder.setToolTip(self.contract_dir)
        self.input_contract_folder.setStyleSheet("background-color: #f8fafc;")
        template_manage_layout.addWidget(self.input_contract_folder, 1, 1)

        btn_open_contract_dir = SecondaryButton("Mở thư mục lưu mẫu hợp đồng")
        btn_open_contract_dir.clicked.connect(
            lambda: self._open_storage_folder(self.contract_dir, "thư mục lưu mẫu hợp đồng")
        )
        template_manage_layout.addWidget(btn_open_contract_dir, 1, 2)

        template_manage_layout.addWidget(QLabel("Quản lý danh sách mẫu:"), 2, 0)
        template_action_row = QHBoxLayout()
        self.btn_import_contract_template = SecondaryButton("Nạp mẫu hợp đồng", icon=_load_icon("action_add_128.png"))
        self.btn_import_contract_template.clicked.connect(self._on_import_contract_template)
        btn_refresh_templates = GhostButton("Làm mới danh sách mẫu hợp đồng")
        btn_refresh_templates.clicked.connect(lambda: self._refresh_contract_resource_state(notify=True))
        template_action_row.addWidget(self.btn_import_contract_template)
        template_action_row.addWidget(btn_refresh_templates)
        template_action_row.addStretch()
        template_manage_layout.addLayout(template_action_row, 2, 1, 1, 2)

        template_manage_layout.addWidget(QLabel("Danh sách mẫu hợp đồng:"), 3, 0)
        self.template_list_table = QTableWidget(0, 4)
        self.template_list_table.setHorizontalHeaderLabels(["STT", "Tên mẫu", "Đường dẫn", "Xem mẫu"])
        apply_table_style(self.template_list_table, stretch_col=2)
        template_header = self.template_list_table.horizontalHeader()
        template_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
        template_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
        self.template_list_table.setColumnWidth(0, 55)
        self.template_list_table.setColumnWidth(3, 120)
        self.template_list_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.template_list_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.template_list_table.setMinimumHeight(220)
        template_manage_layout.addWidget(self.template_list_table, 4, 0, 1, 3)

        help_title = QLabel("Biến phần mềm truyền vào mẫu Word để người dùng tự tạo mẫu:")
        help_title.setStyleSheet(f"font-weight: 600; color: {COLORS['text_primary']};")
        template_manage_layout.addWidget(help_title, 5, 0, 1, 3)

        self.template_hint_box = QTextEdit()
        self.template_hint_box.setPlainText(self._contract_template_variable_help())
        self.template_hint_box.setReadOnly(True)
        self.template_hint_box.setMinimumHeight(220)
        self.template_hint_box.setStyleSheet(
            "background:#f8fafc; border:1px solid #cbd5e1; border-radius:6px;"
            "font-size:12px; padding:8px; line-height: 1.45; font-family: Consolas, 'Courier New', monospace;"
        )
        template_manage_layout.addWidget(self.template_hint_box, 6, 0, 1, 3)
        template_layout.addWidget(template_manage_box)

        product_manage_box = make_group("Quản lý sản phẩm")
        product_manage_layout = QGridLayout()
        product_manage_layout.setSpacing(10)
        product_manage_box.layout().addLayout(product_manage_layout)

        product_note = QLabel(
            "Phần mềm ưu tiên thư mục dưới đây để đọc danh mục sản phẩm mặc định. Bạn vẫn có thể chọn một file Excel khác trực tiếp ở tab Soạn hợp đồng khi cần."
        )
        product_note.setWordWrap(True)
        product_note.setStyleSheet(f"color: {COLORS['text_secondary']};")
        product_manage_layout.addWidget(product_note, 0, 0, 1, 3)

        product_manage_layout.addWidget(QLabel("Thư mục mẫu sản phẩm:"), 1, 0)
        self.input_product_folder = make_input(self.product_dir)
        self.input_product_folder.setReadOnly(True)
        self.input_product_folder.setToolTip(self.product_dir)
        self.input_product_folder.setStyleSheet("background-color: #f8fafc;")
        product_manage_layout.addWidget(self.input_product_folder, 1, 1)

        btn_open_product_dir = SecondaryButton("Mở thư mục lưu mẫu sản phẩm")
        btn_open_product_dir.clicked.connect(
            lambda: self._open_storage_folder(self.product_dir, "thư mục lưu mẫu sản phẩm")
        )
        product_manage_layout.addWidget(btn_open_product_dir, 1, 2)

        product_manage_layout.addWidget(QLabel("File Excel mặc định:"), 2, 0)
        self.input_product_catalog_default = make_input(self.default_product_catalog_path)
        self.input_product_catalog_default.setReadOnly(True)
        self.input_product_catalog_default.setToolTip(self.default_product_catalog_path)
        self.input_product_catalog_default.setStyleSheet("background-color: #f8fafc;")
        product_manage_layout.addWidget(self.input_product_catalog_default, 2, 1)

        product_manage_layout.addWidget(QLabel("Load danh sách sản phẩm:"), 3, 0)
        product_action_row = QHBoxLayout()
        self.btn_load_product_list = SecondaryButton("Load danh sách sản phẩm", icon=_load_icon("action_receipt_128.png"))
        self.btn_load_product_list.clicked.connect(self._load_product_management_list)
        btn_refresh_products = GhostButton("Làm mới thư mục và file sản phẩm")
        btn_refresh_products.clicked.connect(lambda: self._refresh_contract_resource_state(notify=True))
        product_action_row.addWidget(self.btn_load_product_list)
        product_action_row.addWidget(btn_refresh_products)
        product_action_row.addStretch()
        product_manage_layout.addLayout(product_action_row, 3, 1, 1, 2)

        self.product_list_table = QTableWidget(0, 5)
        self.product_list_table.setHorizontalHeaderLabels(["STT", "Tên sản phẩm", "ĐVT", "Đơn giá", "VAT (%)"])
        apply_table_style(self.product_list_table, stretch_col=1)
        product_header = self.product_list_table.horizontalHeader()
        product_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
        product_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
        product_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
        product_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed)
        self.product_list_table.setColumnWidth(0, 55)
        self.product_list_table.setColumnWidth(2, 90)
        self.product_list_table.setColumnWidth(3, 120)
        self.product_list_table.setColumnWidth(4, 90)
        self.product_list_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
        self.product_list_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
        self.product_list_table.setMinimumHeight(260)
        product_manage_layout.addWidget(self.product_list_table, 4, 0, 1, 3)
        product_layout.addWidget(product_manage_box)

        self._refresh_contract_resource_state()

        # ── Section 3: Bảng kê Sản phẩm ──────────────────────
        table_box = make_group("Bảng kê Sản phẩm (Gõ tên hàng để tự động load thông tin)")
        table_layout = QVBoxLayout()
        table_box.layout().addLayout(table_layout)

        # NEW: Toolbar ABOVE the table
        tbtn_row = QHBoxLayout()
        tbtn_row.setContentsMargins(5, 5, 5, 5)
        
        self.btn_add_row = GhostButton("Thêm sản phẩm", icon=_load_icon("action_add_128.png"))
        self.btn_add_row.setStyleSheet(f"color: {COLORS['success']}; border: 1px solid {COLORS['success']}; padding: 6px 12px;")
        self.btn_add_row.clicked.connect(self._add_table_row)
        
        self.btn_del_row = GhostButton("Xóa s.phẩm chọn", icon=_load_icon("action_cancel_128.png"))
        self.btn_del_row.setStyleSheet(f"color: {COLORS['danger']}; border: 1px solid {COLORS['danger']}; padding: 6px 12px;")
        self.btn_del_row.clicked.connect(self._del_table_row)

        self.btn_clear_all = GhostButton("Xóa tất cả", icon=_load_icon("action_delete_128.png"))
        self.btn_clear_all.setStyleSheet(f"color: {COLORS['danger']}; border: 1px solid {COLORS['danger']}; padding: 6px 12px;")
        self.btn_clear_all.clicked.connect(self._clear_table)

        self.lbl_row_count = QLabel("Số lượng: 0 sản phẩm")
        self.lbl_row_count.setStyleSheet(f"color: {COLORS['text_muted']}; font-style: italic; margin-left: 10px;")

        tbtn_row.addWidget(self.btn_add_row)
        tbtn_row.addWidget(self.btn_del_row)
        tbtn_row.addWidget(self.btn_clear_all)
        tbtn_row.addStretch()
        tbtn_row.addWidget(self.lbl_row_count)
        
        table_layout.addLayout(tbtn_row)

        self.table = QTableWidget(0, 8)
        headers = ["", "STT", "Tên Hàng Hóa", "ĐVT", "Số Lượng", "Đơn Giá", "Thuế VAT (%)", "Thành Tiền"]
        self.table.setHorizontalHeaderLabels(headers)
        
        # Apply style first (defaults to stretch_col=1, so we override to 2)
        apply_table_style(self.table, stretch_col=2)
        
        # New Column Widths & Resizing
        header = self.table.horizontalHeader()
        header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)     # Checkbox
        header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)     # STT
        header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)     # ĐVT
        header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed)     # Số Lượng
        header.setSectionResizeMode(6, QHeaderView.ResizeMode.Fixed)     # Thuế
        
        self.table.setColumnWidth(0, 30)  # Checkbox
        self.table.setColumnWidth(1, 50)  # STT (increased width)
        self.table.setColumnWidth(3, 80) # ĐVT
        self.table.setColumnWidth(4, 70)  # Số Lượng
        self.table.setColumnWidth(6, 80) # Thuế
        
        self.table.setShowGrid(True)
        self.table.setEditTriggers(QAbstractItemView.EditTrigger.AllEditTriggers)
        self.table.setMinimumHeight(250)

        # Set delegate for "Tên Hàng" (Col 2) to use QCompleter
        self.table.setItemDelegateForColumn(2, CompleterDelegate(self.completer, self.table))

        # Connect table change for calculation
        self.table.cellChanged.connect(self._on_cell_changed)
        
        table_layout.addWidget(self.table)

        # Bottom Totals row (Moved here from General Info)
        totals_form = make_form()
        totals_form.addRow("Tổng cộng (số):", self.input_total_amount)
        totals_form.addRow("Tổng cộng (chữ):", self.input_total_word)
        table_layout.addLayout(totals_form)

        compose_layout.addWidget(table_box)

        # ── Action Button ──────────────────────────────────
        btn_action_row = QHBoxLayout()
        self.btn_generate = PrimaryButton("Xuất Hợp Đồng Nháp (DOCX)", icon=_load_icon("action_word_128.png"))
        self.btn_generate.setFixedHeight(52)
        self.btn_generate.setFont(QFont("Segoe UI", 11, QFont.Weight.DemiBold))
        btn_contract_style = """
            QPushButton {
                background-color: #ffffff;
                color: #1e40af;
                border: 1px solid #cbd5e1;
                border-radius: 8px;
                padding: 6px 12px;
            }
            QPushButton:hover {
                background-color: #f8fafc;
                border-color: #93c5fd;
            }
            QPushButton:pressed {
                background-color: #eff6ff;
                border-color: #60a5fa;
            }
        """
        self.btn_generate.setStyleSheet(btn_contract_style)
        self.btn_generate.clicked.connect(self._on_generate)
        
        btn_action_row.addStretch()
        btn_action_row.addWidget(self.btn_generate)
        
        self.btn_generate_pdf = SecondaryButton("Xuất Hợp Đồng Chờ Ký (PDF)", icon=_load_icon("action_pdf_128.png"))
        self.btn_generate_pdf.setFixedHeight(52)
        self.btn_generate_pdf.setFont(QFont("Segoe UI", 11, QFont.Weight.DemiBold))
        self.btn_generate_pdf.setStyleSheet(btn_contract_style)
        self.btn_generate_pdf.clicked.connect(self._on_generate_pdf)

        # Force identical button size regardless of style/sizeHint differences.
        btn_width = 250
        self.btn_generate.setFixedSize(btn_width, 52)
        self.btn_generate_pdf.setFixedSize(btn_width, 52)

        btn_action_row.addWidget(self.btn_generate_pdf)
        btn_action_row.addStretch()
        
        compose_layout.addLayout(btn_action_row)
        compose_layout.addStretch()
        template_layout.addStretch()
        product_layout.addStretch()

        scroll.setWidget(body)
        outer.addWidget(scroll)
        
        # Auto-load default catalog if exists
        QTimer.singleShot(500, self._auto_load_default_catalog)
        # Thêm 1 dòng mặc định để người dùng chọn nhanh
        QTimer.singleShot(100, self._add_table_row)
        
        return w

    # ── Load danh sách partners từ DB ──────────────────────────────
    def _load_partners_cache(self):
        """Nạp danh sách khách hàng từ DB vào completer."""
        db_path = getattr(self, '_company_db_path', '')
        if not db_path or not os.path.exists(db_path):
            return
        try:
            conn = sqlite3.connect(db_path)
            conn.row_factory = sqlite3.Row
            available_cols = {r[1] for r in conn.execute("PRAGMA table_info(partners)").fetchall()}
            wanted_cols = ["code", "name", "tax_code", "address", "phone", "representative", "position"]
            select_cols = [c for c in wanted_cols if c in available_cols]
            if not select_cols:
                conn.close()
                return

            rows = conn.execute(
                f"SELECT {', '.join(select_cols)} FROM partners WHERE is_active=1 ORDER BY name"
            ).fetchall()
            conn.close()

            self._partners_cache = []
            for r in rows:
                item = dict(r)
                for c in wanted_cols:
                    item.setdefault(c, "")
                self._partners_cache.append(item)

            # Tạo danh sách gợi ý: "Tên Công Ty (KH001)"
            suggestions = []
            for p in self._partners_cache:
                label = p.get('name', '')
                if p.get('code'):
                    label += f" [{p['code']}]"
                suggestions.append(label)
            model = QStringListModel(suggestions)
            self._client_completer.setModel(model)
        except Exception as e:
            print(f"[HKDTax] load partners error: {e}")

    def _on_client_selected(self, text: str):
        """Tìm bản ghi phù hợp và auto-fill."""
        # Lấy tên gốc bằng cách bỏ phần [KH001]
        name_clean = re.sub(r'\s*\[.*?\]\s*$', '', text).strip()
        matched = next(
            (p for p in self._partners_cache
             if p.get('name', '').strip() == name_clean or
                (p.get('code') and f"[{p['code']}]" in text)),
            None
        )
        if not matched:
            return
        self.input_client.setText(matched.get('name', ''))
        if matched.get('tax_code'):
            self.input_mst.setText(matched['tax_code'])
        if matched.get('phone'):
            self.input_phone.setText(matched['phone'])
        if matched.get('address'):
            self.input_address.setText(matched['address'])
        if matched.get('representative'):
            self.input_rep.setText(matched['representative'])
        if matched.get('position'):
            self.input_position.setText(matched['position'])
        show_toast(self.window(), f"✅ Đã chọn: {matched.get('name', '')}")

    def _on_pick_customer_dialog(self):
        """Mở popup chọn khách hàng từ danh sách."""
        db_path = getattr(self, '_company_db_path', '')
        # Fallback: đọc từ company_manager nếu chưa có
        if not db_path or not os.path.exists(db_path):
            try:
                from core.company_manager import _get_default_db_path
                db_path = _get_default_db_path() or ''
                self._company_db_path = db_path
            except Exception:
                pass
        if not db_path or not os.path.exists(db_path):
            from PySide6.QtWidgets import QMessageBox
            QMessageBox.warning(self, "Chưa có DB",
                "Chưa chọn công ty/database. Vào Cài Đặt để chọn.")
            return
        # Reload cache trước khi mở
        self._load_partners_cache()
        from gui.modules.customers_module import CustomerPickerDialog
        dlg = CustomerPickerDialog(db_path, parent=self)
        dlg.customer_selected.connect(self._fill_from_partner)
        dlg.exec()

    def _fill_from_partner(self, partner: dict):
        """Auto-fill các ô form từ record partner."""
        self.input_client.setText(partner.get('name', ''))
        if partner.get('tax_code'):
            self.input_mst.setText(partner['tax_code'])
        if partner.get('phone'):
            self.input_phone.setText(partner['phone'])
        if partner.get('address'):
            self.input_address.setText(partner['address'])
        if partner.get('representative'):
            self.input_rep.setText(partner['representative'])
        if partner.get('position'):
            self.input_position.setText(partner['position'])
        show_toast(self.window(), f"✅ {partner.get('name', '')}")

    def _on_lookup_mst(self):
        mst = self.input_mst.text().strip()
        if not mst:
            QMessageBox.warning(self, "Lỗi", "Vui lòng nhập Mã số thuế trước khi tra cứu!")
            return

        if self._mst_worker and self._mst_worker.isRunning():
            show_toast(self.window(), "Đang tra cứu MST, vui lòng chờ...")
            return

        show_toast(self.window(), f"⏳ Đang tra cứu MST {mst}...")
        self.btn_lookup_mst.setEnabled(False)
        self._mst_worker = MSTLookupWorker(mst)

        def _on_mst_done(success: bool, biz: dict, err: str):
            self.btn_lookup_mst.setEnabled(True)
            if success:
                name = biz.get("name") or ""
                address = biz.get("address") or ""
                if name:
                    self.input_client.setText(name)
                if address:
                    self.input_address.setText(address)
                self.input_mst.setText(mst)
                show_toast(self.window(), f"✅ Đã tìm thấy: {name or mst}")
            else:
                QMessageBox.warning(self, "Thông báo", err or "Không tìm thấy thông tin mã số thuế.")
            self._mst_worker = None

        self._mst_worker.finished_sig.connect(_on_mst_done)
        self._mst_worker.start()

    def _auto_load_default_catalog(self):
        path = getattr(self, "default_product_catalog_path", os.path.join(self.product_dir, "sanpham.xlsx"))
        if os.path.exists(path):
            if hasattr(self, "input_excel_path"):
                self.input_excel_path.setText(path)
            self._load_excel_data(path, silent=True)

    def _auto_load_company_config(self):
        """Tự động đọc settings.json và điền cấu hình vào form khi mở app."""
        import json
        try:
            from core.utils import get_app_data_path
            settings_path = get_app_data_path("settings.json")
            if not os.path.exists(settings_path):
                return
            with open(settings_path, "r", encoding="utf-8") as f:
                info = json.load(f).get("company", {})
        except Exception:
            return

        # Cập nhật mã hợp đồng theo mẫu mới
        self._refresh_contract_id()

        # Nếu form đã có sẵn các input thông tin Bên B (công ty mình) thì điền vào
        # (Chưa có field riêng cho Bên B trong form hiện tại —
        #  nhưng lưu vào self._company_info để generate_contract sử dụng)
        self._company_info = {
            "ten_cong_ty_b": info.get("legal_name", ""),
            "dia_chi_b":     info.get("address", ""),
            "mst_b":         info.get("tax_id", ""),
            "dai_dien_b":    info.get("representative", ""),
            "chuc_vu_b":     info.get("position", ""),
            "stk_b":         info.get("bank_account", ""),
        }

    def _has_contract_templates(self, folder: str) -> bool:
        try:
            return any(
                f.lower().endswith((".docx", ".doc")) and not f.startswith("~$")
                for f in os.listdir(folder)
            )
        except Exception:
            return False

    def _copy_bundled_contract_templates(self, src_dir: str, dst_dir: str) -> None:
        import shutil

        os.makedirs(dst_dir, exist_ok=True)
        for name in os.listdir(src_dir):
            if not name.lower().endswith((".docx", ".doc")) or name.startswith("~$"):
                continue
            src = os.path.join(src_dir, name)
            dst = os.path.join(dst_dir, name)
            if os.path.isfile(src) and not os.path.exists(dst):
                shutil.copy2(src, dst)

    def _resolve_contract_dir(self) -> str:
        """Find editable contract templates in dev and frozen EXE builds."""
        app_contract_dir = get_app_data_path("contract")
        if self._has_contract_templates(app_contract_dir):
            return app_contract_dir

        # Backward compatible with current builds where the EXE sits inside src/
        # while the contract/ folder is one level above it.
        if getattr(sys, "frozen", False):
            parent_contract_dir = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "contract")
            if self._has_contract_templates(parent_contract_dir):
                return parent_contract_dir

        bundled_contract_dir = get_resource_path("contract")
        if self._has_contract_templates(bundled_contract_dir):
            try:
                self._copy_bundled_contract_templates(bundled_contract_dir, app_contract_dir)
                if self._has_contract_templates(app_contract_dir):
                    return app_contract_dir
            except Exception as e:
                print(f"[HKDTaxPage] cannot copy bundled contract templates: {e}")
            return bundled_contract_dir

        legacy_contract_dir = os.path.join(os.path.dirname(_SRC), "contract")
        if self._has_contract_templates(legacy_contract_dir):
            return legacy_contract_dir

        return app_contract_dir

    def _has_product_catalog(self, folder: str) -> bool:
        try:
            return any(
                f.lower().endswith((".xlsx", ".xls")) and not f.startswith("~$")
                for f in os.listdir(folder)
            )
        except Exception:
            return False

    def _copy_bundled_product_catalog(self, src_dir: str, dst_dir: str) -> None:
        import shutil

        os.makedirs(dst_dir, exist_ok=True)
        for name in os.listdir(src_dir):
            if not name.lower().endswith((".xlsx", ".xls")) or name.startswith("~$"):
                continue
            src = os.path.join(src_dir, name)
            dst = os.path.join(dst_dir, name)
            if os.path.isfile(src) and not os.path.exists(dst):
                shutil.copy2(src, dst)

    def _resolve_product_dir(self) -> str:
        """Find editable product catalogs in dev and frozen EXE builds."""
        app_product_dir = get_app_data_path("product")
        if self._has_product_catalog(app_product_dir):
            return app_product_dir

        if getattr(sys, "frozen", False):
            parent_product_dir = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "product")
            if self._has_product_catalog(parent_product_dir):
                return parent_product_dir

        bundled_product_dir = get_resource_path("product")
        if self._has_product_catalog(bundled_product_dir):
            try:
                self._copy_bundled_product_catalog(bundled_product_dir, app_product_dir)
                if self._has_product_catalog(app_product_dir):
                    return app_product_dir
            except Exception as e:
                print(f"[HKDTaxPage] cannot copy bundled product catalog: {e}")
            return bundled_product_dir

        legacy_product_dir = os.path.join(os.path.dirname(_SRC), "product")
        if self._has_product_catalog(legacy_product_dir):
            return legacy_product_dir

        return app_product_dir

    def _contract_template_limit(self) -> int | None:
        from core.licensing import get_effective_plan_limits

        main_win = self.window()
        plan_id = getattr(main_win, "plan_type", "free") if main_win else "free"
        license_context = getattr(main_win, "license_context", {}) if main_win else {}
        limits = getattr(main_win, "effective_plan_limits", {}) or get_effective_plan_limits(plan_id, license_context)
        return limits.get("max_templates")

    def _contract_template_files(self) -> list[str]:
        if not os.path.exists(self.contract_dir):
            os.makedirs(self.contract_dir, exist_ok=True)
            return []
        return sorted(
            f for f in os.listdir(self.contract_dir)
            if f.lower().endswith(('.docx', '.doc')) and not f.startswith("~$")
        )

    def _get_templates(self):
        files = self._contract_template_files()
        if not files:
            return ["Trống"]
        limit = self._contract_template_limit()
        if isinstance(limit, int) and limit >= 0:
            files = files[:limit]
        return files if files else ["Trống"]

    def _contract_template_variable_help(self) -> str:
        return (
            "Hướng dẫn tạo mẫu Word (.docx)\n"
            "Dùng cú pháp docxtpl/Jinja: {{ ten_bien }}\n"
            "Biến chưa có dữ liệu sẽ tự để trống.\n\n"
            "Biến hợp đồng / bên A:\n"
            "  {{ ma_hop_dong }}\n"
            "  {{ ten_khach_hang }}\n"
            "  {{ dia_chi }}\n"
            "  {{ dien_thoai }}\n"
            "  {{ mst }}\n"
            "  {{ tai_khoan }}\n"
            "  {{ dai_dien_ben_a }}\n"
            "  {{ chuc_vu }}\n"
            "  {{ ngay_ky }}\n"
            "  {{ tong_tien_so }}\n"
            "  {{ tong_tien_chu }}\n\n"
            "Biến công ty / bên B lấy từ Cài đặt:\n"
            "  {{ ten_ben_b }}\n"
            "  {{ dia_chi_ben_b }}\n"
            "  {{ mst_ben_b }}\n"
            "  {{ ma_so_thue }}\n"
            "  {{ dai_dien_ben_b }}\n"
            "  {{ chuc_vu_ben_b }}\n"
            "  {{ dien_thoai_ben_b }}\n"
            "  {{ fax_ben_b }}\n"
            "  {{ website_ben_b }}\n"
            "  {{ email_ben_b }}\n"
            "  {{ tai_khoan_ben_b }}\n\n"
            "Bảng sản phẩm trong Word:\n"
            "  {%tr for item in ds_san_pham %}\n"
            "  {{ item.stt }}\n"
            "  {{ item.ten_sp }}\n"
            "  {{ item.dvt }}\n"
            "  {{ item.so_luong }}\n"
            "  {{ item.don_gia_fmt }}\n"
            "  {{ item.vat_rate }}\n"
            "  {{ item.thanh_tien_fmt }}\n"
            "  {%tr endfor %}"
        )

    def _list_contract_templates(self) -> list[str]:
        return [name for name in self._get_templates() if name != "Trống"]

    def _refresh_template_management_table(self) -> None:
        if not hasattr(self, "template_list_table"):
            return

        templates = self._list_contract_templates()
        self.template_list_table.setRowCount(0)

        for index, template_name in enumerate(templates, start=1):
            row = self.template_list_table.rowCount()
            template_path = os.path.join(self.contract_dir, template_name)
            self.template_list_table.insertRow(row)
            self.template_list_table.setItem(row, 0, QTableWidgetItem(str(index)))
            self.template_list_table.setItem(row, 1, QTableWidgetItem(template_name))
            self.template_list_table.setItem(row, 2, QTableWidgetItem(template_path))

            btn_open_template = GhostButton("Xem mẫu", icon=_load_icon("action_view_128.png"))
            btn_open_template.setFixedHeight(32)
            btn_open_template.clicked.connect(
                lambda _checked=False, file_name=template_name: self._open_contract_template_file(file_name)
            )
            self.template_list_table.setCellWidget(row, 3, btn_open_template)

    def _refresh_product_management_table(self, catalog: dict | None = None) -> None:
        if not hasattr(self, "product_list_table"):
            return

        source_catalog = catalog if catalog is not None else self.product_catalog
        rows = sorted(source_catalog.items(), key=lambda item: item[0].casefold())
        self.product_list_table.setRowCount(0)

        for index, (name, prod) in enumerate(rows, start=1):
            row = self.product_list_table.rowCount()
            self.product_list_table.insertRow(row)
            self.product_list_table.setItem(row, 0, QTableWidgetItem(str(index)))
            self.product_list_table.setItem(row, 1, QTableWidgetItem(name))
            self.product_list_table.setItem(row, 2, QTableWidgetItem(str(prod.get("unit") or "")))
            self.product_list_table.setItem(row, 3, QTableWidgetItem(_format_plain_number(prod.get("price"), group=True)))
            self.product_list_table.setItem(row, 4, QTableWidgetItem(_format_plain_number(prod.get("vat"))))

    def _on_import_contract_template(self) -> None:
        os.makedirs(self.contract_dir, exist_ok=True)
        path, _ = QFileDialog.getOpenFileName(
            self,
            "Chọn mẫu hợp đồng Word",
            self.contract_dir,
            "Word (*.docx *.doc)",
        )
        if not path:
            return

        file_name = os.path.basename(path)
        target_path = os.path.join(self.contract_dir, file_name)
        same_path = os.path.normcase(os.path.abspath(path)) == os.path.normcase(os.path.abspath(target_path))
        existing_templates = self._contract_template_files()
        template_limit = self._contract_template_limit()
        if (
            isinstance(template_limit, int)
            and template_limit >= 0
            and file_name not in existing_templates
            and len(existing_templates) >= template_limit
        ):
            QMessageBox.warning(
                self,
                "Đã đạt giới hạn mẫu hợp đồng",
                f"Gói hiện tại chỉ cho phép tối đa {template_limit} mẫu hợp đồng.\n"
                "Vui lòng nâng gói để nạp thêm mẫu mới.",
            )
            return

        if os.path.exists(target_path) and not same_path:
            reply = QMessageBox.question(
                self,
                "Ghi đè mẫu hợp đồng",
                f"Mẫu {file_name} đã tồn tại. Bạn có muốn ghi đè không?",
                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
                QMessageBox.StandardButton.No,
            )
            if reply != QMessageBox.StandardButton.Yes:
                return

        if not same_path:
            shutil.copy2(path, target_path)

        self._refresh_contract_resource_state()
        if hasattr(self, "combo_template"):
            selected_index = self.combo_template.findText(file_name)
            if selected_index >= 0:
                self.combo_template.setCurrentIndex(selected_index)
        show_toast(self.window(), f"✅ Đã nạp mẫu hợp đồng: {file_name}")

    def _open_contract_template_file(self, template_name: str) -> None:
        template_path = os.path.join(self.contract_dir, template_name)
        if not os.path.exists(template_path):
            QMessageBox.warning(self, "Không tìm thấy mẫu", f"Không tìm thấy file mẫu:\n{template_path}")
            return

        if hasattr(self, "combo_template"):
            selected_index = self.combo_template.findText(template_name)
            if selected_index >= 0:
                self.combo_template.setCurrentIndex(selected_index)

        try:
            os.startfile(template_path)
        except Exception as exc:
            QMessageBox.warning(
                self,
                "Không mở được mẫu hợp đồng",
                f"Không thể mở file mẫu để chỉnh sửa:\n{template_path}\n\nChi tiết: {exc}",
            )

    def _load_product_management_list(self) -> None:
        current_path = self.input_excel_path.text().strip() if hasattr(self, "input_excel_path") else ""
        if current_path and os.path.exists(current_path):
            show_toast(self.window(), "⏳ Đang load danh sách sản phẩm từ file hiện tại...")
            self._start_catalog_load_worker("excel", current_path)
            return

        if self.product_catalog:
            self._refresh_product_management_table()
            show_toast(self.window(), f"✅ Đã hiển thị {len(self.product_catalog)} sản phẩm đang có.")
            return

        default_path = getattr(self, "default_product_catalog_path", "")
        if default_path and os.path.exists(default_path):
            if hasattr(self, "input_excel_path"):
                self.input_excel_path.setText(default_path)
            show_toast(self.window(), "⏳ Đang load danh sách sản phẩm mặc định...")
            self._start_catalog_load_worker("excel", default_path)
            return

        QMessageBox.warning(
            self,
            "Chưa có danh sách sản phẩm",
            "Không tìm thấy file Excel danh mục sản phẩm. Vui lòng nạp file ở tab Soạn hợp đồng hoặc thêm file vào thư mục sản phẩm.",
        )

    def _refresh_contract_resource_state(self, notify: bool = False) -> None:
        previous_template = self.combo_template.currentText() if hasattr(self, "combo_template") else ""
        previous_default_catalog = getattr(self, "default_product_catalog_path", "")
        current_excel_path = self.input_excel_path.text().strip() if hasattr(self, "input_excel_path") else ""

        self.contract_dir = self._resolve_contract_dir()
        self.product_dir = self._resolve_product_dir()
        os.makedirs(self.contract_dir, exist_ok=True)
        os.makedirs(self.product_dir, exist_ok=True)
        self.default_product_catalog_path = os.path.join(self.product_dir, "sanpham.xlsx")

        if hasattr(self, "combo_template"):
            templates = self._get_templates()
            self.combo_template.blockSignals(True)
            self.combo_template.clear()
            self.combo_template.addItems(templates)
            previous_index = self.combo_template.findText(previous_template)
            self.combo_template.setCurrentIndex(previous_index if previous_index >= 0 else 0)
            self.combo_template.blockSignals(False)

        if hasattr(self, "input_contract_folder"):
            self.input_contract_folder.setText(self.contract_dir)
            self.input_contract_folder.setToolTip(self.contract_dir)

        if hasattr(self, "input_product_folder"):
            self.input_product_folder.setText(self.product_dir)
            self.input_product_folder.setToolTip(self.product_dir)

        if hasattr(self, "input_product_catalog_default"):
            self.input_product_catalog_default.setText(self.default_product_catalog_path)
            self.input_product_catalog_default.setToolTip(self.default_product_catalog_path)

        if hasattr(self, "input_excel_path"):
            should_reset_catalog = (
                not current_excel_path
                or not os.path.exists(current_excel_path)
                or (
                    previous_default_catalog
                    and os.path.normcase(os.path.abspath(current_excel_path))
                    == os.path.normcase(os.path.abspath(previous_default_catalog))
                )
            )
            if should_reset_catalog:
                self.input_excel_path.setText(self.default_product_catalog_path)

        self._refresh_template_management_table()
        self._refresh_product_management_table()

        if notify:
            show_toast(self.window(), "Đã làm mới thư mục mẫu hợp đồng và sản phẩm.")

    def _open_storage_folder(self, folder_path: str, label: str) -> None:
        from PySide6.QtWidgets import QApplication

        os.makedirs(folder_path, exist_ok=True)
        try:
            os.startfile(folder_path)
        except Exception as e:
            QApplication.clipboard().setText(folder_path)
            QMessageBox.warning(
                self.window(),
                "Không mở được thư mục",
                f"Không thể mở {label}. Đã copy đường dẫn:\n{folder_path}\n\nChi tiết: {e}",
            )

    def _open_external_url_with_confirm(self, url: str, title: str = "Liên kết ngoài") -> None:
        from PySide6.QtGui import QDesktopServices
        from PySide6.QtWidgets import QApplication

        clean_url = (url or "").strip()
        if not clean_url:
            show_toast(self.window(), "Không có link để mở.", type="warning")
            return

        msg = QMessageBox(self.window())
        msg.setIcon(QMessageBox.Icon.Question)
        msg.setWindowTitle(title)
        msg.setText("Phần mềm chỉ mở link khi bạn xác nhận.")
        msg.setInformativeText(clean_url)
        open_btn = msg.addButton("Mở trình duyệt", QMessageBox.ButtonRole.AcceptRole)
        copy_btn = msg.addButton("Copy link", QMessageBox.ButtonRole.ActionRole)
        msg.addButton("Hủy", QMessageBox.ButtonRole.RejectRole)
        msg.exec()

        clicked = msg.clickedButton()
        if clicked is copy_btn:
            QApplication.clipboard().setText(clean_url)
            show_toast(self.window(), "Đã copy link Google Drive.")
            return

        if clicked is open_btn and not QDesktopServices.openUrl(QUrl(clean_url)):
            QApplication.clipboard().setText(clean_url)
            QMessageBox.warning(
                self.window(),
                title,
                f"Không mở được trình duyệt. Đã copy link:\n{clean_url}",
            )

    def _add_table_row(self):
        self._is_updating_table = True
        row = self.table.rowCount()
        self.table.insertRow(row)
        
        # Checkbox column (Col 0)
        chk_item = QTableWidgetItem()
        chk_item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
        chk_item.setCheckState(Qt.CheckState.Unchecked)
        self.table.setItem(row, 0, chk_item)
        
        # STT column (Col 1)
        self.table.setItem(row, 1, QTableWidgetItem(str(row + 1)))
        
        # Data columns (Col 2-7)
        for col in [2, 3]: # Name and unit
             self.table.setItem(row, col, QTableWidgetItem(""))
        for col in [4, 5, 6, 7]: # Numbers
            self.table.setItem(row, col, QTableWidgetItem("0"))
        self._is_updating_table = False
        
        # Focus on the 'Tên Hàng' cell (Col 2)
        self.table.setCurrentCell(row, 2)
        self.table.editItem(self.table.item(row, 2))

    def _del_table_row(self):
        # 1. Identify rows to delete (checked or current)
        to_del = []
        for r in range(self.table.rowCount()):
            chk = self.table.item(r, 0)
            if chk and chk.checkState() == Qt.CheckState.Checked:
                to_del.append(r)
        
        # If no checkboxes checked, delete current row
        if not to_del:
            curr = self.table.currentRow()
            if curr >= 0:
                to_del.append(curr)
                
        if not to_del: return
        
        # 2. Confirm and Delete
        count = len(to_del)
        ret = QMessageBox.question(self, "Xác nhận xóa", f"Bạn có chắc muốn xóa {count} sản phẩm đã chọn?",
                                 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
        if ret == QMessageBox.StandardButton.Yes:
            self._is_updating_table = True
            for r in sorted(to_del, reverse=True):
                self.table.removeRow(r)
            self._is_updating_table = False
            self._update_stt_and_total()

    def _clear_table(self):
        if self.table.rowCount() == 0: return
        ret = QMessageBox.question(self, "Xác nhận", "Bạn có chắc chắn muốn xóa toàn bộ danh sách sản phẩm không?",
                                 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
        if ret == QMessageBox.StandardButton.Yes:
            self._is_updating_table = True
            self.table.setRowCount(0)
            self._is_updating_table = False
            self._update_stt_and_total()
            show_toast(self.window(), "Đã xóa toàn bộ sản phẩm.")

    def _safe_float(self, val):
        """Chuyển đổi an toàn sang float, xử lý các ký tự đặc biệt như %, VNĐ, dấu phẩy."""
        return _coerce_number(val)

    def _on_cell_changed(self, row, col):
        if self._is_updating_table: return
        
        # If "Tên Hàng" (col 2) changed, try to auto-fill
        if col == 2:
            name = self.table.item(row, 2).text().strip() if self.table.item(row, 2) else ""
            if name in self.product_catalog:
                self._is_updating_table = True
                prod = self.product_catalog[name]
                self.table.setItem(row, 3, QTableWidgetItem(prod['unit']))
                self.table.setItem(row, 5, QTableWidgetItem(_format_plain_number(prod['price'], group=True)))
                self.table.setItem(row, 6, QTableWidgetItem(_format_plain_number(prod['vat'])))
                
                # Default quantity to 1 if it's currently 0 or empty
                qty_item = self.table.item(row, 4)
                qty = self._safe_float(qty_item.text() if qty_item else "0")
                if qty == 0:
                    qty = 1
                    self.table.setItem(row, 4, QTableWidgetItem("1"))
                
                # Recalculate amount for this row
                amount = qty * prod['price'] * (1 + prod['vat']/100)
                self.table.setItem(row, 7, QTableWidgetItem(_format_plain_number(amount, group=True)))
                self._is_updating_table = False
                self._update_stt_and_total()
                return

        # If Quantity, Price or VAT changed, recalculate Amount
        if col in [4, 5, 6]:
            self._is_updating_table = True
            qty = self._safe_float(self.table.item(row, 4).text() if self.table.item(row, 4) else "0")
            price = self._safe_float(self.table.item(row, 5).text() if self.table.item(row, 5) else "0")
            vat_rate = self._safe_float(self.table.item(row, 6).text() if self.table.item(row, 6) else "0")
            
            amount = qty * price * (1 + vat_rate/100)
            self.table.setItem(row, 7, QTableWidgetItem(_format_plain_number(amount, group=True)))
            self._is_updating_table = False
            self._update_stt_and_total()

    def _update_stt_and_total(self):
        self._is_updating_table = True
        total = 0
        for r in range(self.table.rowCount()):
            # Re-index STT
            self.table.setItem(r, 1, QTableWidgetItem(str(r + 1)))
            # Sum totals
            if self.table.item(r, 7):
                total += self._safe_float(self.table.item(r, 7).text())
        
        self.input_total_amount.setText(_format_plain_number(total, group=True, max_decimals=0))
        if total > 0:
            self.input_total_word.setText(number_to_vietnamese_words(int(total)))
        else:
            self.input_total_word.clear()
            
        # Update row count label
        if hasattr(self, "lbl_row_count"):
            self.lbl_row_count.setText(f"Số lượng: {self.table.rowCount()} sản phẩm")
            
        self._is_updating_table = False

    def _is_blank_product_row(self, row: int) -> bool:
        name = self.table.item(row, 2).text().strip() if self.table.item(row, 2) else ""
        unit = self.table.item(row, 3).text().strip() if self.table.item(row, 3) else ""
        qty = self._safe_float(self.table.item(row, 4).text() if self.table.item(row, 4) else "0")
        price = self._safe_float(self.table.item(row, 5).text() if self.table.item(row, 5) else "0")
        vat_rate = self._safe_float(self.table.item(row, 6).text() if self.table.item(row, 6) else "0")
        amount = self._safe_float(self.table.item(row, 7).text() if self.table.item(row, 7) else "0")
        return not name and not unit and qty == 0 and price == 0 and vat_rate == 0 and amount == 0

    def _append_catalog_to_table(self, catalog: dict) -> int:
        if not catalog:
            return 0

        self._is_updating_table = True
        try:
            for row in range(self.table.rowCount() - 1, -1, -1):
                if self._is_blank_product_row(row):
                    self.table.removeRow(row)

            for name, prod in catalog.items():
                row = self.table.rowCount()
                self.table.insertRow(row)

                chk_item = QTableWidgetItem()
                chk_item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable)
                chk_item.setCheckState(Qt.CheckState.Unchecked)
                self.table.setItem(row, 0, chk_item)
                self.table.setItem(row, 1, QTableWidgetItem(str(row + 1)))

                price = self._safe_float(prod.get('price'))
                vat_rate = self._safe_float(prod.get('vat'))
                amount = price * (1 + vat_rate / 100)

                self.table.setItem(row, 2, QTableWidgetItem(name))
                self.table.setItem(row, 3, QTableWidgetItem(str(prod.get('unit') or "")))
                self.table.setItem(row, 4, QTableWidgetItem("1"))
                self.table.setItem(row, 5, QTableWidgetItem(_format_plain_number(price, group=True)))
                self.table.setItem(row, 6, QTableWidgetItem(_format_plain_number(vat_rate)))
                self.table.setItem(row, 7, QTableWidgetItem(_format_plain_number(amount, group=True)))
        finally:
            self._is_updating_table = False

        self._update_stt_and_total()
        return len(catalog)

    def _on_load_excel(self):
        current_path = self.input_excel_path.text().strip() if hasattr(self, "input_excel_path") else ""
        start_dir = os.path.dirname(current_path) if current_path and os.path.isdir(os.path.dirname(current_path)) else self.product_dir
        path, _ = QFileDialog.getOpenFileName(self, "Chọn file Excel Danh mục SP", start_dir, "Excel (*.xlsx *.xls)")
        if path:
            self.input_excel_path.setText(path)
            show_toast(self.window(), "⏳ Đang nạp danh mục từ Excel...")
            self._start_catalog_load_worker("excel", path)

    def _load_excel_data(self, path, silent=False):
        if not os.path.exists(path):
            if not silent:
                QMessageBox.warning(self, "Không tìm thấy file", f"Không tìm thấy file danh mục:\n{path}")
            return
        self._start_catalog_load_worker("excel", path, silent=silent)

    def _on_load_gs(self):
        link = self.input_gs_link.text().strip()
        if not link:
            QMessageBox.warning(self, "Thiếu link", "Vui lòng nhập link Google Sheets.")
            return
        show_toast(self.window(), "⏳ Đang tải dữ liệu từ Google Sheets...")
        self._start_catalog_load_worker("sheets", link)

    def _start_catalog_load_worker(self, mode: str, payload: str, silent: bool = False):
        if self._catalog_worker and self._catalog_worker.isRunning():
            if not silent:
                show_toast(self.window(), "Đang nạp danh mục, vui lòng chờ...")
            return

        if hasattr(self, "btn_load_excel"):
            self.btn_load_excel.setEnabled(False)
        if hasattr(self, "btn_load_gs"):
            self.btn_load_gs.setEnabled(False)
        if hasattr(self, "btn_load_product_list"):
            self.btn_load_product_list.setEnabled(False)

        self._catalog_worker = CatalogLoadWorker(mode, payload)

        def _on_done(success: bool, catalog: dict, err: str):
            if hasattr(self, "btn_load_excel"):
                self.btn_load_excel.setEnabled(True)
            if hasattr(self, "btn_load_gs"):
                self.btn_load_gs.setEnabled(True)
            if hasattr(self, "btn_load_product_list"):
                self.btn_load_product_list.setEnabled(True)

            if success:
                self.product_catalog = catalog
                self._update_completer()
                self._refresh_product_management_table(catalog)
                if not silent:
                    source = "Google Sheets" if mode == "sheets" else "Excel"
                    added_count = self._append_catalog_to_table(catalog)
                    if added_count:
                        show_toast(self.window(), f"✅ Đã thêm {added_count} sản phẩm từ {source} vào danh sách.")
                    else:
                        show_toast(self.window(), f"Không có sản phẩm nào để thêm từ {source}.")
            else:
                if not silent:
                    QMessageBox.critical(self, "Lỗi", f"Không thể nạp danh mục:\n{err}")

            self._catalog_worker = None

        self._catalog_worker.finished_sig.connect(_on_done)
        self._catalog_worker.start()

    def _on_push_gs(self):
        link = self.input_gs_link.text().strip()
        if not link:
            QMessageBox.warning(self, "Thiếu link", "Vui lòng nhập link Google Sheets để đẩy dữ liệu.")
            return

        try:
            secret_files = [f for f in os.listdir(_SRC) if f.startswith("client_secret_") and f.endswith(".json")]
            if not secret_files: return

            secret_path = os.path.join(_SRC, secret_files[0])
            import gspread
            gc = gspread.service_account(filename=secret_path)
            sh = gc.open_by_url(link)
            worksheet = sh.get_worksheet(0)

            # Prepare data
            headers = ["STT", "Tên Hàng", "ĐVT", "Số Lượng", "Đơn Giá", "Thuế VAT", "Thành Tiền"]
            rows = [headers]
            for r in range(self.table.rowCount()):
                row_data = []
                for c in range(7):
                    item = self.table.item(r, c)
                    row_data.append(item.text() if item else "")
                rows.append(row_data)

            worksheet.clear()
            worksheet.update('A1', rows)
            show_toast(self.window(), "Đã cập nhật dữ liệu lên Google Sheets thành công!")
        except Exception as e:
            QMessageBox.critical(self, "Lỗi", f"Không thể đẩy dữ liệu: {e}")

    def _update_completer(self):
        """Cập nhật danh sách gợi ý cho tìm kiếm sản phẩm."""
        self.product_names = sorted(list(self.product_catalog.keys()))
        model = QStringListModel(self.product_names)
        self.completer.setModel(model)
        # Note: Completer is applied to the cell editor in the table directly.

    def _refresh_contract_id(self):
        """Tạo mã hợp đồng theo cấu hình người dùng."""
        main = self.window()
        fmt = "HD-{YYYY}{MM}{DD}-{RAND4}"  # Default
        if hasattr(main, "company_info"):
            fmt = main.company_info.get("contract_format", fmt)

        now = datetime.now()

        # Replace date tags
        res = fmt.replace("{YYYY}", now.strftime("%Y"))
        res = res.replace("{YY}", now.strftime("%y"))
        res = res.replace("{MM}", now.strftime("%m"))
        res = res.replace("{DD}", now.strftime("%d"))

        # Replace time tags
        res = res.replace("{HH}", now.strftime("%H"))
        res = res.replace("{mm}", now.strftime("%M"))
        res = res.replace("{SS}", now.strftime("%S"))

        # Replace random tags
        if "{RAND4}" in res:
            rand4 = ''.join(random.choices(string.ascii_uppercase + string.digits, k=4))
            res = res.replace("{RAND4}", rand4)
        if "{RAND2}" in res:
            rand2 = ''.join(random.choices(string.ascii_uppercase + string.digits, k=2))
            res = res.replace("{RAND2}", rand2)

        self.input_code.setText(res)

    def _on_generate(self):
        self._start_generate_worker(to_pdf=False)

    def _on_generate_pdf(self):
        self._start_generate_worker(to_pdf=True)

    def _collect_products_for_export(self) -> list[dict]:
        """Thu thập và validate tối thiểu danh sách sản phẩm trước khi xuất hợp đồng."""
        products = []
        for r in range(self.table.rowCount()):
            name = self.table.item(r, 2).text().strip() if self.table.item(r, 2) else ""
            qty = self._safe_float(self.table.item(r, 4).text()) if self.table.item(r, 4) else 0
            price = self._safe_float(self.table.item(r, 5).text()) if self.table.item(r, 5) else 0
            vat = self._safe_float(self.table.item(r, 6).text()) if self.table.item(r, 6) else 0
            amount = self._safe_float(self.table.item(r, 7).text()) if self.table.item(r, 7) else 0

            # Bỏ qua dòng trống hoàn toàn.
            if not name and qty == 0 and price == 0 and vat == 0 and amount == 0:
                continue

            if not name:
                raise ValueError(f"Dòng {r + 1}: thiếu tên hàng hóa.")
            if qty <= 0:
                raise ValueError(f"Dòng {r + 1}: số lượng phải lớn hơn 0.")

            products.append({
                'stt': self.table.item(r, 1).text() if self.table.item(r, 1) else "",
                'ten_sp': name,
                'dvt': self.table.item(r, 3).text() if self.table.item(r, 3) else "",
                'so_luong': qty,
                'don_gia': price,
                'vat_rate': vat,
                'thanh_tien': amount,
            })

        if not products:
            raise ValueError("Vui lòng thêm ít nhất 1 sản phẩm trước khi xuất hợp đồng.")
        return products

    def _validate_header_fields(self):
        """Validate các trường bắt buộc ở phần thông tin chung."""
        if not self.input_code.text().strip():
            self.input_code.setFocus()
            raise ValueError("Vui lòng nhập mã hợp đồng.")
        if not self.input_client.text().strip():
            self.input_client.setFocus()
            raise ValueError("Vui lòng nhập tên khách hàng/bên đối tác.")
        if not self.input_mst.text().strip():
            self.input_mst.setFocus()
            raise ValueError("Vui lòng nhập mã số thuế.")
        if not self.input_rep.text().strip():
            self.input_rep.setFocus()
            raise ValueError("Vui lòng nhập người đại diện.")

    def _start_generate_worker(self, to_pdf: bool):
        # 0. Check License Expiration
        main = self.window()
        if hasattr(main, "is_expired") and main.is_expired:
            QMessageBox.critical(self, "Bản quyền hết hạn", 
                "Bản quyền của bạn đã hết hạn sử dụng (Trial 1 năm).\n"
                "Vui lòng liên hệ hỗ trợ để gia hạn và tiếp tục sử dụng tính năng Xuất Hợp Đồng!")
            return

        try:
            from core.contracts_db import count_contracts_total
            from core.licensing import can_create_contract, get_plan_family

            plan_id = getattr(main, "plan_type", "free")
            current_count = count_contracts_total()
            allowed, limit, family = can_create_contract(plan_id, current_count, getattr(main, "license_context", {}))
            if not allowed:
                package_name = {
                    "free": "FREE",
                    "trial": "TRIAL",
                    "starter": "STARTER",
                    "pro": "PRO",
                    "office": "OFFICE",
                }.get(get_plan_family(plan_id), str(plan_id).upper())
                QMessageBox.warning(
                    self,
                    "Đã đạt giới hạn gói",
                    f"Gói {package_name} đã đạt giới hạn {current_count}/{limit} hợp đồng.\n"
                    "Vui lòng nâng gói để tiếp tục xuất hợp đồng.",
                )
                return
        except Exception:
            pass

        try:
            template_name = self.combo_template.currentText()
            if template_name == "Trống":
                QMessageBox.warning(self, "Lỗi", "Chưa chọn mẫu hợp đồng.")
                return

            self._validate_header_fields()
            products = self._collect_products_for_export()

            self.template_path = os.path.join(self.contract_dir, template_name)

            context = {
                'ma_hop_dong': self.input_code.text(),
                'ten_khach_hang': self.input_client.text(),
                'dia_chi': self.input_address.text(),
                'dien_thoai': self.input_phone.text(),
                'fax': self.input_fax.text(),
                'mst': self.input_mst.text(),
                'tai_khoan': self.input_account.text(),
                'dai_dien_ben_a': self.input_rep.text(),
                'chuc_vu': self.input_position.text(),
                'ngay_ky': self.input_date.date().toString("dd/MM/yyyy"),
                'tong_tien_so': self.input_total_amount.text(),
                'tong_tien_chu': self.input_total_word.text(),
                'ds_san_pham': format_product_list(products)
            }

            try:
                import json
                from core.utils import get_app_data_path
                settings_path = get_app_data_path("settings.json")
                if os.path.exists(settings_path):
                    with open(settings_path, "r", encoding="utf-8") as f:
                        info = json.load(f).get("company", {})
                        context.update({
                            'ten_ben_b':      info.get("legal_name", "[Chưa thiết lập tên Bên B]"),
                            'dia_chi_ben_b':  info.get("address", "[Chưa thiết lập địa chỉ Bên B]"),
                            'mst_ben_b':      info.get("tax_id", "[Chưa thiết lập MST Bên B]"),
                            'ma_so_thue':     info.get("tax_id", "[Chưa thiết lập MST Bên B]"), 
                            'dai_dien_ben_b': info.get("representative", "[Chưa thiết lập đại diện]"),
                            'chuc_vu_ben_b':  info.get("position", "[Chưa thiết lập chức vụ Bên B]"),
                            'dien_thoai_ben_b': info.get("phone", "[Chưa thiết lập SĐT Bên B]"),
                            'fax_ben_b':      info.get("fax", "[Chưa thiết lập Fax Bên B]"),
                            'website_ben_b':  info.get("website", "[Chưa thiết lập Website Bên B]"),
                            'email_ben_b':     info.get("email", "[Chưa thiết lập Email Bên B]"),
                            'tai_khoan_ben_b': info.get("bank_account", "[Chưa thiết lập TK Ngân hàng]"),
                        })
            except Exception:
                pass

            category = "sale" if self.combo_contract_type.currentIndex() == 0 else "purchase"
            cat_subdir = self._category_dir_name(category)
            
            from core.drive_sync import _ROOT
            now = datetime.now()
            date_str = now.strftime("%Y-%m-%d")
            
            target_dir = get_company_root_path(os.path.join("contracts", cat_subdir, "data", date_str, "pending"))
            os.makedirs(target_dir, exist_ok=True)
            
            c_id = self.input_code.text()
            # Khi xuất PDF: dùng file tạm để không đè lên file nháp .docx
            docx_path = os.path.join(target_dir, f"{c_id}_tmp.docx" if to_pdf else f"{c_id}.docx")
            
            show_toast(self.window(), "⏳ Đang xuất hợp đồng (vui lòng đợi)...")
            self.btn_generate.setEnabled(False)
            self.btn_generate_pdf.setEnabled(False)

            from .hkd_tax_module import GenerateWorker
            self.gen_worker = GenerateWorker(self.template_path, docx_path, context, to_pdf)
            self.gen_worker.finished_sig.connect(lambda success, path, cat=category, c=context, d=date_str, c_id=c_id: 
                self._on_generate_finished(success, path, cat, c, d, c_id))
            self.gen_worker.start()

        except ValueError as e:
            QMessageBox.warning(self, "Thiếu thông tin", str(e))
        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi", str(e))

    def _on_mail_click_legacy(self, contract_item: dict, category: str):
        """Mở dialog gửi mail."""
        import os
        import urllib.parse

        contract_type = "Bán Hàng" if category == "sale" else "Mua Hàng"
        c_name = contract_item.get("name_file") or contract_item.get("name", "")
        # Bên đối tác: sale → party_a (khách), purchase → party_b (nhà cung cấp)
        partner = (
            contract_item.get("party_a") or contract_item.get("party_b") or ""
            if category == "sale"
            else contract_item.get("party_b") or contract_item.get("party_a") or ""
        )
        partner_str = f" - Công ty {partner}" if partner else ""
        subject = f"[Hợp Đồng {contract_type}] {c_name}{partner_str}"
        # Strip mọi suffix ký khỏi c_name để lấy tên gốc sạch
        import re as _re2
        _STRIP_SFXRE = _re2.compile(
            r'[_ ](aky|bky|aky[_ ]bky|bky[_ ]aky|daky|d?a?ky|asign|bsign|ky_?[ab]|signed?|sign).*$',
            _re2.IGNORECASE)
        pdf_name = _STRIP_SFXRE.sub('', c_name)  # VD: HD-20260404-QUNR_bky → HD-20260404-QUNR

        # Lấy tên file đính kèm thực tế làm gốc cho return_name
        att_path = contract_item.get("path", "")
        if att_path:
            att_stem = os.path.splitext(os.path.basename(att_path))[0]  # VD: 20260327-HDDTNGP-ALMW_bky
        else:
            att_stem = pdf_name  # fallback

        # Detect both_signed từ tên file (tin cậy hơn sign_sequence DB)
        _has_aky = bool(re.search(r'[_]aky', att_stem, re.IGNORECASE))
        _has_bky = bool(re.search(r'[_]bky', att_stem, re.IGNORECASE))
        both_signed = _has_aky and _has_bky

        if both_signed:
            return_name = att_stem   # đã đủ 2 chữ ký
        elif category == "sale":
            return_name = f"{att_stem}_aky"   # đối tác thêm _aky
        else:
            return_name = f"{att_stem}_bky"   # đối tác thêm _bky

        # ── Đọc template từ settings.json (trực tiếp, tránh stale memory) ────
        try:
            import json as _json
            from core.utils import get_app_data_path
            _sfile = get_app_data_path("settings.json")
            _cfg = _json.load(open(_sfile, encoding="utf-8")).get("company", {}) if os.path.exists(_sfile) else {}
        except Exception:
            _cfg = {}
        our_company = _cfg.get("legal_name", "") or (
            getattr(self.window(), "company_info", {}) or {}
        ).get("legal_name", "Công ty chúng tôi")
        mail_tpl = _cfg.get("mail_body", "")
        if not mail_tpl:
            # Fallback: default template
            if both_signed:
                mail_tpl = (
                    "Kính gửi Quý đối tác {partner},\n\n"
                    "Công ty chúng tôi ({our_company}) kính gửi hợp đồng số {c_id} "
                    "đã được ký đầy đủ bởi cả hai bên.\n\n"
                    "📎 Đính kèm: {att_stem}.pdf\n\n"
                    "Trân trọng,\n{our_company}"
                )
            elif category == "sale":
                mail_tpl = (
                    "Kính gửi Quý đối tác {partner},\n\n"
                    "Công ty chúng tôi ({our_company}) xin gửi đến Quý đối tác "
                    "hợp đồng số {c_id} đính kèm theo email này.\n\n"
                    "Quý đối tác vui lòng xem xét, ký và gửi lại bản đã ký cho chúng tôi.\n\n"
                    "📎 Lưu ý khi gửi lại:\n"
                    "  • File đính kèm: {att_stem}.pdf (hợp đồng đã có chữ ký của chúng tôi)\n"
                    "  • Khi ký xong, vui lòng đặt tên file: {return_name}.pdf\n"
                    "  • Gửi reply email này hoặc gửi về địa chỉ email của chúng tôi.\n\n"
                    "Trân trọng,\n{our_company}"
                )
            else:
                mail_tpl = (
                    "Kính gửi Quý đối tác {partner},\n\n"
                    "Công ty chúng tôi ({our_company}) xin gửi đến Quý đối tác "
                    "hợp đồng số {c_id} đính kèm theo email này.\n\n"
                    "Quý đối tác vui lòng xem xét, ký và gửi lại bản đã ký cho chúng tôi.\n\n"
                    "📎 Lưu ý khi gửi lại:\n"
                    "  • File đính kèm: {att_stem}.pdf (hợp đồng đã có chữ ký của chúng tôi)\n"
                    "  • Khi ký xong, vui lòng đặt tên file: {return_name}.pdf\n"
                    "  • Gửi reply email này hoặc gửi về địa chỉ email của chúng tôi.\n\n"
                    "Trân trọng,\n{our_company}"
                )

        # Thay thế các placeholder
        if not both_signed:
            mail_tpl = self._ensure_mail_software_guide(mail_tpl)

        body = (
            mail_tpl
            .replace("{partner}",     partner or "Quý đối tác")
            .replace("{our_company}", our_company)
            .replace("{c_id}",        pdf_name)
            .replace("{att_stem}",    att_stem)
            .replace("{return_name}", return_name)
        )

        mailto_link = f"mailto:?subject={urllib.parse.quote(subject)}&body={urllib.parse.quote(body)}"
        try:
            os.startfile(mailto_link)
        except Exception as e:
            QMessageBox.warning(self.window(), "Lỗi", f"Không thể mở ứng dụng mail mặc định: {e}")

    def _on_share_click_legacy(self, contract_item: dict, category: str):
        """Chia sẻ link Google Drive của hợp đồng kèm nội dung hướng dẫn ký."""
        if not self._ensure_plan_feature("has_backup_sync", "Share Link Drive", "STARTER, PRO hoặc OFFICE"):
            return

        import traceback, json as _json, re as _re
        try:
            from core.drive_sync import _ROOT, get_contract_share_link
            import os

            c_path = contract_item.get("path")
            if not c_path:
                QMessageBox.warning(self.window(), "Lỗi", "Không tìm thấy đường dẫn file!")
                return

            drive_id = contract_item.get("drive_id")
            if not drive_id:
                QMessageBox.warning(self.window(), "Lỗi", "File này chưa được đồng bộ lên Google Drive. Khuyến cáo chạy 'Đồng bộ' lại!")
                return

            from PySide6.QtWidgets import QApplication
            show_toast(self.window(), "⏳ Đang lấy link chia sẻ...")
            QApplication.processEvents()

            link = get_contract_share_link(drive_id)
            if not link:
                QMessageBox.warning(self.window(), "Lỗi", "Thu thập link chia sẻ thất bại!")
                return

            # ── Lấy thông tin hợp đồng ───────────────────────────────────────
            pdf_name  = contract_item.get("name_file", "")
            att_stem  = os.path.splitext(pdf_name)[0]
            partner   = contract_item.get("client_name", "") or "Quý đối tác"

            _has_aky = _re.search(r'_aky', att_stem)
            _has_bky = _re.search(r'_bky', att_stem)
            both_signed = bool(_has_aky and _has_bky)
            if category == "sale":
                return_name = att_stem + "_aky"
            else:
                return_name = att_stem + "_bky"

            # ── Đọc settings ─────────────────────────────────────────────────
            try:
                from core.utils import get_app_data_path
                _sfile = get_app_data_path("settings.json")
                _cfg = _json.load(open(_sfile, encoding="utf-8")).get("company", {}) if os.path.exists(_sfile) else {}
            except Exception:
                _cfg = {}
            our_company = _cfg.get("legal_name", "") or "Công ty chúng tôi"

            # ── Link hướng dẫn ký (có thể tuỳ chỉnh) ────────────────────────
            guide_link = "https://support.google.com/drive/answer/2494822"

            # ── Tạo nội dung tin nhắn ────────────────────────────────────────
            if both_signed:
                msg = (
                    f"Kính gửi {partner},\n\n"
                    f"({our_company}) xin gửi hợp đồng số {att_stem} đã được ký đầy đủ "
                    f"bởi cả hai bên.\n\n"
                    f"📄 Xem tại: {link}\n\n"
                    f"Trân trọng,\n{our_company}"
                )
            elif category == "sale":
                msg = (
                    f"Kính gửi {partner},\n\n"
                    f"({our_company}) xin gửi hợp đồng số {att_stem} để Quý đối tác xem xét và ký.\n\n"
                    f"📄 Xem & tải hợp đồng: {link}\n\n"
                    f"📝 Hướng dẫn ký trên Google Drive: {guide_link}\n\n"
                    f"📎 Lưu ý khi gửi lại:\n"
                    f"  • Khi ký xong, vui lòng đặt tên file: {return_name}.pdf\n"
                    f"  • Gửi file về địa chỉ email của chúng tôi.\n\n"
                    f"Trân trọng,\n{our_company}"
                )
            else:
                msg = (
                    f"Kính gửi {partner},\n\n"
                    f"({our_company}) xin gửi hợp đồng số {att_stem} để Quý đối tác xem xét và ký.\n\n"
                    f"📄 Xem & tải hợp đồng: {link}\n\n"
                    f"📝 Hướng dẫn ký trên Google Drive: {guide_link}\n\n"
                    f"📎 Lưu ý khi gửi lại:\n"
                    f"  • Khi ký xong, vui lòng đặt tên file: {return_name}.pdf\n"
                    f"  • Gửi file về địa chỉ email của chúng tôi.\n\n"
                    f"Trân trọng,\n{our_company}"
                )

            # ── Dialog hiện nội dung ──────────────────────────────────────────
            from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
                QLabel, QTextEdit, QPushButton)
            from PySide6.QtCore import Qt

            dlg = QDialog(self.window())
            dlg.setWindowTitle("Chia sẻ Hợp đồng qua Drive")
            dlg.resize(540, 400)
            lay = QVBoxLayout(dlg)
            lay.setSpacing(10)

            lay.addWidget(QLabel("<b>Nội dung tin nhắn (copy để gửi qua Zalo / Messenger):</b>"))
            txt = QTextEdit()
            txt.setPlainText(msg)
            txt.setStyleSheet("font-size:13px; line-height:1.5;")
            lay.addWidget(txt)

            btn_row = QHBoxLayout()
            btn_copy_msg = QPushButton("📋 Copy tin nhắn")
            btn_copy_link = QPushButton("🔗 Copy link Drive")
            btn_zalo  = QPushButton("Mở Zalo Web")
            btn_close = QPushButton("Đóng")

            import pyperclip
            btn_copy_msg.clicked.connect(lambda: (pyperclip.copy(txt.toPlainText()), show_toast(self.window(), "✅ Đã copy nội dung tin nhắn!")))
            btn_copy_link.clicked.connect(lambda: (pyperclip.copy(link), show_toast(self.window(), "✅ Đã copy link Drive!")))
            btn_zalo.clicked.connect(lambda: os.startfile("https://chat.zalo.me/"))
            btn_close.clicked.connect(dlg.accept)

            btn_copy_msg.setStyleSheet("background:#0ea5e9;color:white;padding:6px 14px;border-radius:4px;font-weight:bold;")
            btn_copy_link.setStyleSheet("background:#22c55e;color:white;padding:6px 14px;border-radius:4px;font-weight:bold;")

            btn_row.addWidget(btn_copy_msg)
            btn_row.addWidget(btn_copy_link)
            btn_row.addWidget(btn_zalo)
            btn_row.addStretch()
            btn_row.addWidget(btn_close)
            lay.addLayout(btn_row)
            dlg.exec()

        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi", f"Có lỗi xảy ra: {e}\n{traceback.format_exc()}")

    def _on_import_click(self, contract_item: dict, category: str):
        """Nhập bản PDF/DOCX đã ký: suffix-first + verify_pdf_signature fallback → phân tuyến completed/pending."""
        from PySide6.QtWidgets import QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QFileDialog, QMessageBox
        from PySide6.QtCore import Qt
        import os, re as _re, shutil
        from core.contracts_db import write_contract_sidecar

        c_name = contract_item.get("name_file")
        if not c_name:
            return

        dlg = QDialog(self.window())
        dlg.setWindowTitle(f"Nhập bản ký — {c_name}")
        dlg.resize(480, 150)
        layout = QVBoxLayout(dlg)
        layout.addWidget(QLabel("Chọn file PDF (hoặc DOCX) hợp đồng:"))
        file_row = QHBoxLayout()
        txt_path = QLineEdit(); txt_path.setReadOnly(True)
        txt_path.setPlaceholderText("Đường dẫn file…")
        btn_browse = QPushButton("Duyệt…")
        file_row.addWidget(txt_path); file_row.addWidget(btn_browse)
        layout.addLayout(file_row)

        def do_browse():
            p, _ = QFileDialog.getOpenFileName(
                dlg, "Chọn file hợp đồng", "",
                "Hợp đồng (*.pdf *.docx);;PDF (*.pdf);;Word (*.docx)")
            if p: txt_path.setText(p)
        btn_browse.clicked.connect(do_browse)
        layout.addStretch()
        btn_row = QHBoxLayout(); btn_row.addStretch()
        btn_cancel = QPushButton("Hủy"); btn_cancel.setFixedWidth(80)
        btn_ok = QPushButton("Đồng ý Import"); btn_ok.setFixedWidth(140)
        btn_ok.setStyleSheet("background:#0ea5e9;color:white;font-weight:bold;padding:6px 12px;border-radius:4px;")
        btn_cancel.clicked.connect(dlg.reject); btn_ok.clicked.connect(dlg.accept)
        btn_row.addWidget(btn_cancel); btn_row.addWidget(btn_ok)
        layout.addLayout(btn_row)

        if dlg.exec() != QDialog.DialogCode.Accepted:
            return
        path = txt_path.text().strip()
        if not path or not os.path.exists(path):
            QMessageBox.warning(self.window(), "Lỗi", "Vui lòng chọn file hợp lệ.")
            return

        ext    = os.path.splitext(path)[1].lower()
        c_date = contract_item.get("date", "")
        cat_dir = self._category_dir_name(category)

        # ── DOCX → pending (trạng thái nháp xác định bởi đuôi .docx) ──────────
        if ext == ".docx":
            dest_dir = get_company_root_path(
                os.path.join("contracts", cat_dir, "data", c_date, "pending"))
            os.makedirs(dest_dir, exist_ok=True)
            shutil.copy2(path, os.path.join(dest_dir, f"{c_name}.docx"))
            show_toast(self.window(), f"✅ Đã lưu bản nháp {c_name}.docx vào pending/")
            from core.contracts_db import scan_contract_files
            scan_contract_files(category, full_refresh=True)
            self._reload_table(category)
            return

        # ── BƯỚC 1: Regex suffix engine ──────────────────────────────────────
        _SA_PAT   = r"(?:a(?:ky|sign|_?signed?)|ky_?a|sign_?a|ben_?a)"
        _SB_PAT   = r"(?:b(?:ky|sign|_?signed?)|ky_?b|sign_?b|ben_?b)"
        _BOTH_PAT = rf"(?:{_SA_PAT}[_ ]{_SB_PAT}|{_SB_PAT}[_ ]{_SA_PAT})"
        _SUFFIX_RE = _re.compile(
            rf"(?i)[_ ]({_BOTH_PAT}|{_SA_PAT}|{_SB_PAT}|d?a?ky|signed?|sign)([_ ].*)?$")
        base_name = os.path.splitext(os.path.basename(path))[0]
        _sfx_m   = _SUFFIX_RE.search(base_name)
        _sfx_str = _sfx_m.group(0).lower() if _sfx_m else ""
        fn_has_a = bool(_re.search(_SA_PAT, _sfx_str, _re.I)) if _sfx_str else False
        fn_has_b = bool(_re.search(_SB_PAT, _sfx_str, _re.I)) if _sfx_str else False

        # ── BƯỚC 2: Xác minh chữ ký số ──────────────────────────────────────
        warn = False
        try:
            from core.pdf_signature_verifier import verify_pdf_signature
            import json
            from core.utils import get_app_data_path
            host_mst = ""
            try:
                with open(get_app_data_path("settings.json"), encoding="utf-8") as _f:
                    host_mst = str(json.load(_f).get("company", {}).get("tax_id", "")).strip().lower()
            except Exception:
                pass
            vr = verify_pdf_signature(path)
            if fn_has_a or fn_has_b:
                # Có suffix → verify để cảnh báo vàng nếu không xác minh được
                if not vr.get("is_signed") or not vr.get("is_valid", True):
                    warn = True
            else:
                # Không suffix → dùng verify để tự phân loại
                sig_host = sig_other = False
                for sig in vr.get("signatures", []):
                    mst = sig.get("mst", "").strip().lower()
                    if mst and host_mst and mst.replace("-", "") == host_mst.replace("-", ""):
                        sig_host = True
                    elif mst:
                        sig_other = True
                if vr.get("is_signed") and not vr.get("signatures"):
                    sig_other = True
                if category == "sale":
                    fn_has_a = sig_other; fn_has_b = sig_host
                else:
                    fn_has_a = sig_host;  fn_has_b = sig_other
                if not (fn_has_a or fn_has_b):
                    confirm = QMessageBox.question(
                        self.window(), "Không tìm thấy chữ ký số",
                        f"File không chứa chữ ký số.\nĐây có phải bản scan tay của HĐ {c_name}?\n"
                        "(Yes → nhập vào pending, No → hủy)",
                        QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
                    if confirm != QMessageBox.StandardButton.Yes:
                        return
        except Exception:
            warn = True

        # ── BƯỚC 3: Phân tuyến ───────────────────────────────────────────────
        if fn_has_a and fn_has_b:
            seq_suffix = "_aky_bky"; target_folder = "completed"
        elif fn_has_a:
            seq_suffix = "_aky";     target_folder = "pending"
        elif fn_has_b:
            seq_suffix = "_bky";     target_folder = "pending"
        else:
            seq_suffix = "";         target_folder = "pending"

        dest_dir = get_company_root_path(
            os.path.join("contracts", cat_dir, "data", c_date, target_folder))
        os.makedirs(dest_dir, exist_ok=True)
        target_path = os.path.join(dest_dir, f"{c_name}{seq_suffix}.pdf")
        try:
            shutil.copy2(path, target_path)
        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi Import", str(e))
            return

        try:
            sidecar_payload = {
                "name_file": c_name,
                "name": contract_item.get("name", ""),
                "party_a": contract_item.get("party_a", ""),
                "party_b": contract_item.get("party_b", ""),
                "mst_a": contract_item.get("mst_a", ""),
                "mst_b": contract_item.get("mst_b", ""),
                "date": c_date,
                "note": contract_item.get("note", ""),
                "amount_total": contract_item.get("amount_total", 0),
            }
            write_contract_sidecar(target_path, sidecar_payload)
        except Exception as exc:
            print(f"[HKDTaxPage] cannot write sidecar for imported contract {c_name}: {exc}")

        if warn:
            show_toast(self.window(),
                f"⚠️ Đã import {c_name} nhưng KHÔNG xác minh được chữ ký số!",
                type="warning")
        else:
            show_toast(self.window(), f"✅ Đã import {c_name} → {target_folder}/")

        from core.contracts_db import scan_contract_files
        scan_contract_files(category, full_refresh=True)
        self._reload_table(category)

        if fn_has_a and fn_has_b:
            self._prompt_revenue_entry_for_completed_imports(category, [c_name])


    def _sync_imap_contracts(self, category, contracts_data):
        """Kết nối IMAP tải file PDF chứa chữ ký từ Mail khách gửi."""
        if not self._plan_has_feature("has_email_sync"):
            return {"success": False, "message": "Gói hiện tại chưa bao gồm tải đồng bộ Data từ Email.", "downloaded": 0}

        import json, os, datetime
        from core.drive_sync import _ROOT
        
        from core.utils import get_app_data_path
        settings_path = get_app_data_path("settings.json")
        email_addr = ""
        email_pwd = ""
        imap_server = "imap.gmail.com"
        
        if os.path.exists(settings_path):
            try:
                with open(settings_path, "r", encoding="utf-8") as f:
                    cfg = json.load(f).get("company", {})
                    email_addr = cfg.get("mail_config", {}).get("user", "")
                    email_pwd = cfg.get("mail_config", {}).get("pass", "")
                    imap_server = cfg.get("mail_config", {}).get("imap_server", "imap.gmail.com")
            except: pass
            
        if not email_addr or not email_pwd:
            return {"success": False, "message": "Thiếu cấu hình email (user/pass IMAP).", "downloaded": 0}

        # ── Build lookup từ CẢ 2 bảng DB (sale + purchase) ─────────────────────
        from core.contracts_db import get_connection
        pending_both: dict[str, dict] = {}   # name_file → {"category": "sale"/"purchase", ...}
        try:
            with get_connection() as _conn:
                for _cat, _tbl in (("sale", "contracts_sale"), ("purchase", "contracts_purchase"), ("other", "contracts_other")):
                    rows = _conn.execute(
                        f"SELECT name_file, date, sign_sequence, path FROM {_tbl}"
                        f" WHERE sign_sequence IS NULL OR sign_sequence NOT LIKE '%a%' OR sign_sequence NOT LIKE '%b%'"
                    ).fetchall()
                    for r in rows:
                        nm = r["name_file"]
                        if nm and nm not in pending_both:
                            pending_both[nm] = {"category": _cat, "date": r["date"] or "", "path": r["path"] or ""}
        except Exception as _e:
            print(f"[IMAP] Lỗi build pending_both: {_e}")

        if not pending_both:
            return {"success": True, "message": "Không có hợp đồng chờ ký để dò trong mail.", "downloaded": 0}

        # Regex trích mã HĐ từ subject: ví dụ "HD-20260404-QUNR"
        import re as _re_subj
        _CODE_RE = _re_subj.compile(r'\b(HD[-_]\d{8}[-_][A-Z0-9]{4,})\b', _re_subj.IGNORECASE)

        try:
            from imap_tools import MailBox, A
            import re as _re
            from core.pdf_signature_verifier import verify_pdf_signature
            import tempfile

            # Regex suffix (giống luồng import button)
            _SA_PAT = r'(?:a(?:ky|sign|_?signed?)|ky_?a|sign_?a|ben_?a)'
            _SB_PAT = r'(?:b(?:ky|sign|_?signed?)|ky_?b|sign_?b|ben_?b)'
            _BOTH_PAT = rf'(?:{_SA_PAT}[_ ]{_SB_PAT}|{_SB_PAT}[_ ]{_SA_PAT})'
            _SUFFIX_RE = _re.compile(
                rf'(?i)[_ ]({_BOTH_PAT}|{_SA_PAT}|{_SB_PAT}|d?a?ky|signed?|sign)([_ ].*)?$'
            )

            # Quét mail trong vòng 14 ngày qua
            date_limit = datetime.date.today() - datetime.timedelta(days=14)
            count_downloaded = 0

            with MailBox(imap_server).login(email_addr, email_pwd) as mailbox:
                for msg in mailbox.fetch(A(date_gte=date_limit), limit=50, reverse=True, mark_seen=False):
                    # ── Trích mã HĐ từ subject để tra DB ─────────────────────
                    subj = msg.subject or ""
                    code_match = _CODE_RE.search(subj)
                    subj_code = code_match.group(1).upper() if code_match else None


                    for att in msg.attachments:
                        filename = att.filename.lower()
                        if not filename.endswith(".pdf"):
                            continue

                        # ── Tìm c_name khớp: ưu tiên mã trích từ subject, fallback attachment name ──
                        matched_c_name = None
                        if subj_code and subj_code in pending_both:
                            matched_c_name = subj_code
                        else:
                            for nm in pending_both:
                                if str(nm).lower() in filename:
                                    matched_c_name = nm
                                    break
                        if not matched_c_name:
                            continue

                        c = pending_both[matched_c_name]
                        inferred_category = c["category"]
                        cat_dir_msg = self._category_dir_name(inferred_category)
                        c_name = matched_c_name
                        c_date = c.get("date", "")
                        base_att = os.path.splitext(att.filename)[0]

                        # BƯỚC 1: Đọc suffix từ tên file đính kèm
                        _sfx_m  = _SUFFIX_RE.search(base_att)
                        _sfx_str = _sfx_m.group(0).lower() if _sfx_m else ""
                        fn_has_a = bool(_re.search(_SA_PAT, _sfx_str, _re.I)) if _sfx_str else False
                        fn_has_b = bool(_re.search(_SB_PAT, _sfx_str, _re.I)) if _sfx_str else False

                        # BƯỚC 2: Fallback verify chữ ký số nếu không có suffix
                        if not (fn_has_a or fn_has_b):
                            with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
                                tmp.write(att.payload)
                                tmp_path = tmp.name
                            try:
                                vr = verify_pdf_signature(tmp_path)
                                sig_host = sig_other = False
                                host_mst_cfg = ""
                                try:
                                    import json
                                    from core.utils import get_app_data_path
                                    with open(get_app_data_path("settings.json"), encoding="utf-8") as _f:
                                        host_mst_cfg = str(json.load(_f).get("company", {}).get("tax_id", "")).strip().lower()
                                except: pass
                                for sig in vr.get("signatures", []):
                                    mst = sig.get("mst", "").strip().lower()
                                    if mst and host_mst_cfg and mst.replace("-","") == host_mst_cfg.replace("-",""):
                                        sig_host = True
                                    elif mst:
                                        sig_other = True
                                if vr.get("is_signed") and not vr.get("signatures"):
                                    sig_other = True
                                if inferred_category == "sale":
                                    fn_has_a = sig_other; fn_has_b = sig_host
                                else:
                                    fn_has_a = sig_host;  fn_has_b = sig_other
                            except Exception:
                                pass
                            finally:
                                try: os.remove(tmp_path)
                                except: pass

                        # BƯỚC 3: Phân tuyến
                        if fn_has_a and fn_has_b:
                            seq_suffix = "_aky_bky"; target_folder = "completed"
                        elif fn_has_a:
                            seq_suffix = "_aky";     target_folder = "pending"
                        elif fn_has_b:
                            seq_suffix = "_bky";     target_folder = "pending"
                        else:
                            seq_suffix = "";         target_folder = "pending"

                        dest_dir = get_company_root_path(os.path.join("contracts", cat_dir_msg, "data", c_date, target_folder))
                        os.makedirs(dest_dir, exist_ok=True)
                        target_path = os.path.join(dest_dir, f"{c_name}{seq_suffix}.pdf")
                        if not os.path.exists(target_path):
                            with open(target_path, "wb") as f_out:
                                f_out.write(att.payload)
                            count_downloaded += 1

            if count_downloaded > 0:
                print(f"IMAP: Tải thành công {count_downloaded} file đính kèm.")
                # Quét lại db
                from core.contracts_db import scan_contract_files
                scan_contract_files(category, full_refresh=True)
                return {"success": True, "message": f"Đã tải {count_downloaded} file từ mail.", "downloaded": count_downloaded}

            return {"success": True, "message": "Không có file hợp đồng mới trong mail.", "downloaded": 0}

        except ImportError:
            print("Chưa cài đặt imap_tools, vui lòng chạy: pip install imap-tools")
            return {"success": False, "message": "Chưa cài thư viện imap_tools.", "downloaded": 0}
        except Exception as e:
            print(f"IMAP Sync Error: {e}")
            return {"success": False, "message": str(e), "downloaded": 0}

    def _on_check_mail_clicked(self, category: str):
        """Nút thủ công: quét email để lấy hợp đồng ký trả về."""
        try:
            if not self._has_complete_mail_config():
                show_toast(self.window(), "⚠️ Chưa cấu hình đầy đủ Email trong Cài đặt.", type="warning")
                return
            if not self._ensure_plan_feature("has_email_sync", "Kiểm tra Mail", "TRIAL, PRO hoặc OFFICE"):
                return
            btn = self.sender()
            if btn is not None:
                try:
                    btn.setEnabled(False)
                    btn.setText("Đang kiểm tra...")
                except Exception:
                    pass

            show_toast(self.window(), "⏳ Đang kiểm tra mail (chạy nền)...")

            worker = MailCheckWorker(self, category)
            self._mail_check_workers = [w for w in self._mail_check_workers if w.isRunning()]
            self._mail_check_workers.append(worker)

            def _done(res):
                try:
                    self._on_load_status_clicked(category, silent=True)
                    self._reload_table(category)
                except Exception:
                    pass

                if res.get("success"):
                    show_toast(self.window(), f"✅ {res.get('message', 'Đã kiểm tra mail.')}" )
                else:
                    show_toast(self.window(), f"❌ Kiểm tra mail lỗi: {res.get('message', 'Không rõ lỗi')}" , type="error")

                if btn is not None:
                    try:
                        btn.setEnabled(True)
                        btn.setText("Kiểm tra Mail")
                    except Exception:
                        pass

                try:
                    self._mail_check_workers.remove(worker)
                except ValueError:
                    pass

            worker.finished_sig.connect(_done)
            worker.start()
        except Exception as e:
            show_toast(self.window(), f"❌ Lỗi kiểm tra mail: {e}", type="error")

    def _on_load_status_clicked(self, category: str, silent: bool = False):
        """Quét file → re-classify theo suffix-first + verify fallback → di chuyển nếu sai thư mục → cập nhật DB → reload bảng."""
        import re as _re, os, shutil, json
        from core.contracts_db import scan_contract_files
        from core.utils import get_company_root_path, get_app_data_path
        from core.pdf_signature_verifier import verify_pdf_signature

        if not silent:
            show_toast(self.window(), "⏳ Đang quét và phân loại lại hợp đồng...")

        # ── Regex suffix engine ─────────────────────────────────────────────
        _SA_PAT   = r'(?:a(?:ky|sign|_?signed?)|ky_?a|sign_?a|ben_?a)'
        _SB_PAT   = r'(?:b(?:ky|sign|_?signed?)|ky_?b|sign_?b|ben_?b)'
        _BOTH_PAT = rf'(?:{_SA_PAT}[_ ]{_SB_PAT}|{_SB_PAT}[_ ]{_SA_PAT})'
        _SUFFIX_RE = _re.compile(
            rf'(?i)[_ ]({_BOTH_PAT}|{_SA_PAT}|{_SB_PAT}|d?a?ky|signed?|sign)([_ ].*)?$'
        )

        # Đọc MST công ty
        host_mst_cfg = ""
        try:
            with open(get_app_data_path("settings.json"), encoding="utf-8") as _f:
                host_mst_cfg = str(json.load(_f).get("company", {}).get("tax_id", "")).strip().lower()
        except Exception:
            pass

        cat_dir   = self._category_dir_name(category)
        moved     = 0
        warn_files: list = []

        try:
            base_dir = get_company_root_path(os.path.join("contracts", cat_dir, "data"))
            if os.path.isdir(base_dir):
                for date_sub in os.listdir(base_dir):
                    for folder_name in ("pending", "completed"):
                        src_folder = os.path.join(base_dir, date_sub, folder_name)
                        if not os.path.isdir(src_folder):
                            continue
                        for fname in os.listdir(src_folder):
                            if not fname.lower().endswith(".pdf"):
                                continue
                            fpath = os.path.join(src_folder, fname)
                            base_name = os.path.splitext(fname)[0]

                            # BƯỚC 1: Suffix từ tên file
                            _sfx_m   = _SUFFIX_RE.search(base_name)
                            _sfx_str = _sfx_m.group(0).lower() if _sfx_m else ""
                            fn_has_a = bool(_re.search(_SA_PAT, _sfx_str, _re.I)) if _sfx_str else False
                            fn_has_b = bool(_re.search(_SB_PAT, _sfx_str, _re.I)) if _sfx_str else False

                            # BƯỚC 2: Fallback verify chữ ký số nếu không có suffix
                            if not (fn_has_a or fn_has_b):
                                try:
                                    vr = verify_pdf_signature(fpath)
                                    sig_host = sig_other = False
                                    for sig in vr.get("signatures", []):
                                        mst = sig.get("mst", "").strip().lower()
                                        if mst and host_mst_cfg and mst.replace("-","") == host_mst_cfg.replace("-",""):
                                            sig_host = True
                                        elif mst:
                                            sig_other = True
                                    if vr.get("is_signed") and not vr.get("signatures"):
                                        sig_other = True
                                    if category == "sale":
                                        fn_has_a = sig_other; fn_has_b = sig_host
                                    else:
                                        fn_has_a = sig_host;  fn_has_b = sig_other
                                except Exception:
                                    pass
                            else:
                                # Có suffix → chỉ cảnh báo nếu file ở completed (đáng lẽ cả 2 bên ký số)
                                # File pending (_bky hoặc _aky) là hợp lệ — không cần cảnh báo
                                if folder_name == "completed":
                                    try:
                                        vr = verify_pdf_signature(fpath)
                                        if not vr.get("is_signed") or not vr.get("is_valid", True):
                                            warn_files.append(fname)
                                    except Exception:
                                        warn_files.append(fname)

                            # BƯỚC 3: Xác định thư mục đúng và di chuyển nếu cần
                            correct_folder = "completed" if (fn_has_a and fn_has_b) else "pending"
                            if correct_folder != folder_name:
                                dest_dir = os.path.join(base_dir, date_sub, correct_folder)
                                os.makedirs(dest_dir, exist_ok=True)
                                dest_path = os.path.join(dest_dir, fname)
                                if not os.path.exists(dest_path):
                                    shutil.move(fpath, dest_path)
                                    moved += 1
        except Exception as e:
            import traceback
            print(f"[Lỗi re-classify] {e}\n{traceback.format_exc()}")

        # Sau khi re-classify xong → scan DB
        try:
            result  = scan_contract_files(category, full_refresh=True)
            added   = result.get("added", 0)
            updated = result.get("updated", 0)
            total   = result.get("total", 0)
            if not silent:
                msg = f"✅ Làm mới xong! {total} HĐ"
                if moved:   msg += f" | ↪️ {moved} chuyển thư mục"
                if added:   msg += f" | +{added} mới"
                if updated: msg += f" | ↺ {updated} cập nhật"
                show_toast(self.window(), msg)
                if warn_files:
                    show_toast(
                        self.window(),
                        "⚠️ Không xác minh được chữ ký số:\n" + "\n".join(warn_files),
                        type="warning"
                    )
        except Exception as e:
            import traceback
            print(f"[Lỗi scan] {e}\n{traceback.format_exc()}")
            show_toast(self.window(), f"❌ Lỗi khi quét: {e}")

        self._reload_table(category)




    def _on_sign_status_clicked(self, category: str):
        """Mở quy trình ký điện tử dùng module sign/ (USB Token)."""
        from PySide6.QtWidgets import QTableWidgetItem
        
        # Phải chọn 1 dòng ở bảng để ký
        if category == "sale":
            table = self.table_sale
        elif category == "purchase":
            table = self.table_purchase
        else:
            table = self.table_other
        curr = table.currentRow()
        if curr < 0:
            QMessageBox.warning(self.window(), "Cảnh báo", "Bạn vui lòng bấm chọn một hợp đồng cụ thể trên lưới dữ liệu bên dưới để Ký.")
            return
            
        item = table.item(curr, 2)   # col 2 = Số HĐ (col 1 là Ngày ký)
        if not item: return
        c_id = item.text()
        
        from core.contracts_db import get_connection, own_sign_sequence, filename_from_sequence
        from core.drive_sync import _ROOT
        import os
        
        db_table = self._category_table_name(category)
        c_name_file = item.text()
        
        with get_connection() as conn:
            row = conn.execute(
                f"SELECT path, sign_sequence, date FROM {db_table} WHERE name_file = ? ORDER BY modified_at DESC LIMIT 1",
                (c_name_file,)
            ).fetchone()
            
        if not row:
            QMessageBox.warning(self.window(), "Lỗi", f"Không tìm thấy dữ liệu cho hợp đồng {c_name_file}")
            return
            
        c_path = row["path"]
        current_seq = row["sign_sequence"] or ""
        c_date = row["date"]

        # contracts_other: gop luong Nhap + Ky linh hoat vao 1 bang chon.
        if category == "other":
            self._open_other_flexible_sign_dialog_from_list(
                c_name_file, c_path or "", current_seq, c_date
            )
            return
        
        # ── Guard: Chỉ ký được khi đã có PDF ──────────
        if not c_path.lower().endswith(".pdf"):
            QMessageBox.information(self.window(), "Chưa thể ký",
                f"Hợp đồng {c_name_file} đang ở trạng thái NHÁP (chỉ có file .docx).\n\n"
                "Vui lòng xuất PDF (nhấn 'XUẤT HỢP ĐỒNG CHỜ KÝ') trước, "
                "sau đó mới có thể ký điện tử.")
            return

        # Kiểm tra xem mình đã ký chưa, sinh sequence mới
        new_seq = own_sign_sequence(db_table, current_seq)
        if new_seq == current_seq:
            QMessageBox.information(self.window(), "Thông báo", "Hợp đồng này đại diện công ty đã ký rồi.")
            return

        # Hợp đồng bán hàng: mình ký vai trò Bên B (người mua).
        # Hợp đồng mua hàng: mình ký vai trò Bên A (người bán).
        # Hỗ trợ nhiều nhãn chữ ký phổ biến theo từng vai trò để không cần tách tab hợp đồng.
        if category == "sale":
            role = "Bên B (Đại diện bên Mua)"
            keyword_candidates = [
                "ĐẠI DIỆN BÊN B",
                "BÊN B",
                "BÊN MUA",
            ]
        elif category == "purchase":
            role = "Bên A (Đại diện bên Bán)"
            keyword_candidates = [
                "ĐẠI DIỆN BÊN A",
                "BÊN A",
                "BÊN BÁN",
            ]
        else:
            role = "Bên B (Hợp đồng khác)"
            keyword_candidates = [
                "ĐẠI DIỆN BÊN B",
                "BÊN B",
                "BÊN MUA",
            ]
        keyword = keyword_candidates[0]
        
        input_pdf = get_company_root_path(c_path)
        if not os.path.exists(input_pdf):
            QMessageBox.warning(self.window(), "Lỗi", f"Không tìm thấy file PDF đầu vào:\n{input_pdf}")
            return
            
        # Xác định thư mục đầu ra và tên file mới
        new_filename = filename_from_sequence(c_name_file, new_seq)
        parts = [p.strip() for p in new_seq.split(",") if p.strip()]
        is_fully_signed = ("a" in parts and "b" in parts)
        
        out_folder_name = "completed" if is_fully_signed else "pending"
        out_dir = get_company_root_path(os.path.join("contracts", db_table, "data", c_date, out_folder_name))
        os.makedirs(out_dir, exist_ok=True)
        output_pdf = os.path.join(out_dir, new_filename)
        
        reply = QMessageBox.question(self.window(), "Xác nhận Ký",
            f"Hợp đồng: {c_name_file}\nVai trò: {role}\n"
            f"Từ khóa ưu tiên: \"{keyword}\" (hệ thống sẽ tự động tìm các nhãn tương đương nếu không tìm thấy)\n\n"
            "Hệ thống sẽ mở cửa sổ để Quý khách chọn chứng chỉ USB Token.\nBạn có muốn tiếp tục không?")
        
        if reply != QMessageBox.StandardButton.Yes:
            return
            
        show_toast(self.window(), f"⏳ Đang thực hiện ký số cho {c_name_file}, vui lòng cắm USB Token...")
        from PySide6.QtWidgets import QApplication
        QApplication.processEvents()
        
        try:
            import sys, gc, time, ctypes
            
            # Tìm thư mục sign/ - hỗ trợ cả chạy thường (dev) và chạy từ EXE (PyInstaller)
            if getattr(sys, "frozen", False):
                # Đang chạy từ EXE (PyInstaller): sign/ được giải nén vào _MEIPASS/sign
                sign_dir = os.path.join(sys._MEIPASS, "sign")
            else:
                # Dev mode: tìm sign/ bên cạnh thư mục src/
                sign_dir = os.path.normpath(os.path.join(
                    os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "sign"))
                if not os.path.isdir(sign_dir):
                    sign_dir = os.path.normpath(os.path.join(_ROOT, "..", "sign"))
                
            if not os.path.isdir(sign_dir):
                QMessageBox.critical(self.window(), "Lỗi",
                    f"Không tìm thấy thư mục module ký số.\nĐường dẫn tìm: {sign_dir}")
                return
            
            if sign_dir not in sys.path:
                sys.path.insert(0, sign_dir)
            
            # Đọc timestamp config từ sign.yaml
            ts_url = "http://timestamp.digicert.com"
            yaml_path = os.path.join(sign_dir, "sign.yaml")
            try:
                import yaml
                with open(yaml_path, "r", encoding="utf-8") as yf:
                    cfg = yaml.safe_load(yf)
                ts_cfg = cfg.get("signing", {}).get("timestamp", {})
                if ts_cfg.get("enabled", True):
                    ts_url = ts_cfg.get("url", "http://timestamp.digicert.com") or None
                else:
                    ts_url = None
            except Exception:
                pass
            
            from win_signer import select_signer
            from sign_pdf import PDFSigner, find_keyword_position
            
            signer_obj = select_signer()
            if not signer_obj:
                show_toast(self.window(), "⚠️ Đã hủy chọn chứng chỉ.")
                return

            # Tự dò nhãn chữ ký theo thứ tự ưu tiên để tương thích nhiều mẫu hợp đồng Việt Nam.
            for candidate in keyword_candidates:
                if find_keyword_position(input_pdf, candidate):
                    keyword = candidate
                    break
            
            pdf_engine = PDFSigner(None, None)
            success_signed = False
            
            for _attempt in range(3):
                try:
                    pdf_engine.sign_pdf_native(
                        in_file=input_pdf,
                        out_file=output_pdf,
                        keyword=keyword,
                        signer=signer_obj,
                        offsets=(-45, 0),
                        ts_url=ts_url,
                        use_logo=True
                    )
                    success_signed = True
                    break
                except Exception as sign_err:
                    err_str = str(sign_err)
                    if "0x80090016" in err_str or "0x80090020" in err_str or "THIẾU THIẾT BỊ" in err_str:
                        res = ctypes.windll.user32.MessageBoxW(0,
                            f"LỖI PHẦN CỨNG: {err_str}\n\nVui lòng cắm lại USB Token rồi nhấn [Retry].",
                            "Yêu cầu USB Token", 0x00000005 | 0x00000030)
                        if res != 4: break  # IDRETRY = 4
                    else:
                        QMessageBox.critical(self.window(), "Lỗi ký", f"Ký thất bại:\n{err_str}")
                        break
            
            del signer_obj
            gc.collect()
            time.sleep(0.5)
            
            if success_signed and os.path.exists(output_pdf):
                # Xóa file cũ nếu tên file/đường dẫn thay đổi
                if os.path.abspath(input_pdf) != os.path.abspath(output_pdf):
                    try: os.remove(input_pdf)
                    except: pass
                
                # Cập nhật DB qua hàm scan
                from core.contracts_db import scan_contract_files
                scan_contract_files(category, full_refresh=True)
                self._reload_table(category)
                
                show_toast(self.window(), f"✅ Ký số thành công! Lưu tại: {output_pdf}")
                if QMessageBox.question(self.window(), "Mở file", "Bạn có muốn mở file đã ký không?") == QMessageBox.StandardButton.Yes:
                    self._open_pdf_in_pdf_tools(output_pdf)
            elif success_signed:
                QMessageBox.warning(self.window(), "Cảnh báo", "Quy trình ký đã chạy nhưng không tìm thấy file đầu ra.")
                
        except ImportError as ie:
            QMessageBox.critical(self.window(), "Lỗi Import Module Ký",
                f"Không thể import module ký số từ thư mục sign/.\nLỗi: {ie}\n\nCần cài: pip install -r sign/requirements.txt")
        except Exception as e:
            import traceback
            QMessageBox.critical(self.window(), "Lỗi Ký", f"Lỗi không mong đợi:\n{e}\n\n{traceback.format_exc()[:800]}")

    def _on_download_file_clicked(self, category: str):
        """Mở thư mục lưu file hợp đồng của dòng đang chọn."""
        from PySide6.QtWidgets import QMessageBox
        import os
        from core.drive_sync import _ROOT
        from core.contracts_db import get_connection

        if category == "sale":
            table = self.table_sale
        elif category == "purchase":
            table = self.table_purchase
        else:
            table = self.table_other
        curr = table.currentRow()
        if curr < 0:
            QMessageBox.warning(self.window(), "Chưa chọn", "Bạn vui lòng chọn một hợp đồng trên danh sách.")
            return

        item = table.item(curr, 2)  # col 2 = Số HĐ
        if not item: return
        c_name = item.text()
        db_table = self._category_table_name(category)

        with get_connection() as conn:
            row = conn.execute(f"SELECT path FROM {db_table} WHERE name_file=?", (c_name,)).fetchone()
            
        if not row or not row["path"]:
            QMessageBox.warning(self.window(), "Lỗi", f"Không tìm thấy đường dẫn file cho hợp đồng {c_name}")
            return
            
        # Đường dẫn tuyệt đối
        abs_path = get_company_root_path(row["path"].replace("/", os.sep))
        if not os.path.exists(abs_path):
            QMessageBox.warning(self.window(), "Không tìm thấy file", f"Không tìm thấy file hợp đồng {c_name} trên đĩa:\n{abs_path}")
            return
            
        folder_to_open = os.path.dirname(abs_path)

        try:
            os.startfile(folder_to_open)
            show_toast(self.window(), f"📂 Đang mở thư mục: {folder_to_open}")
        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi mở thư mục", str(e))

    def _on_generate_finished(self, success: bool, file_path: str, category: str, context: dict, date_str: str, c_id: str):
        self.btn_generate.setEnabled(True)
        self.btn_generate_pdf.setEnabled(True)
        
        if not success:
            QMessageBox.critical(self.window(), "Lỗi xuất file", file_path)
            return

        # Xử lý fallback khi docx2pdf lỗi
        if file_path.startswith("__DOCX_FALLBACK__|"):
            parts = file_path.split("|", 2)
            file_path = parts[1]
            err_msg = parts[2] if len(parts) > 2 else "Không rõ lỗi"
            QMessageBox.warning(self.window(), "⚠️ Không chuyển được sang PDF",
                f"Không thể chuyển sang PDF (cần cài Microsoft Word hoặc LibreOffice).\n\n"
                f"Lỗi: {err_msg}\n\n"
                f"Hợp đồng đã được lưu dạng DOCX:\n{file_path}")

        # Cập nhật danh sách (không dùng metadata JSON nữa)
        try:
            self._save_generated_contract_metadata(category, file_path, context, date_str, c_id)

            show_toast(self.window(), f"✅ Xuất hợp đồng thành công!\n{file_path}")

            # ── Chuyển sang tab danh sách + tự gọi Load (im lặng) ──────────
            if category == "sale":
                tab_index = 1
            elif category == "purchase":
                tab_index = 2
            else:
                tab_index = 3
            self.tabs.setCurrentIndex(tab_index)
            self._on_load_status_clicked(category, silent=True)
            
        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi cập nhật CSDL", str(e))


    def _go_page(self, category: str, delta: int):
        if category == "sale":
            self.current_page_sale += delta
        elif category == "purchase":
            self.current_page_purchase += delta
        else:
            self.current_page_other += delta
        self._reload_table(category, reset_page=False)

    def _reload_table(self, category: str, reset_page: bool = True):
        """Tải dữ liệu từ SQLite contracts.db vào bảng."""
        from PySide6.QtWidgets import QTableWidgetItem, QLabel, QWidget, QHBoxLayout, QPushButton
        from PySide6.QtCore import Qt
        from gui.ui_helpers import IconButton
        from core.contracts_db import get_connection, status_from_sequence

        if category == "sale":
            table = self.table_sale
        elif category == "purchase":
            table = self.table_purchase
        else:
            table = self.table_other
        table.setRowCount(0)

        # Lấy năm được chọn từ combo
        try:
            if category == "sale":
                sel_year = self.combo_year_sale.currentText()
            elif category == "purchase":
                sel_year = self.combo_year_purchase.currentText()
            else:
                sel_year = self.combo_year_other.currentText()
        except Exception:
            from datetime import datetime
            sel_year = str(datetime.now().year)

        # Lấy status filter
        try:
            if category == "sale":
                btns = self.status_btns_sale
            elif category == "purchase":
                btns = self.status_btns_purchase
            else:
                btns = self.status_btns_other
            sel_status = next(
                (k for k, b in btns.items() if b.property("active") == "true"),
                "all"
            )
        except Exception:
            sel_status = "all"
            
        # Lấy search
        try:
            if category == "sale":
                search_box = self.search_sale
            elif category == "purchase":
                search_box = self.search_purchase
            else:
                search_box = self.search_other
            search_text = search_box.text().strip().lower()
        except Exception:
            search_text = ""

        if reset_page:
            if category == "sale":
                self.current_page_sale = 1
            elif category == "purchase":
                self.current_page_purchase = 1
            else:
                self.current_page_other = 1

        db_table = self._category_table_name(category)

        def make_status_btn(status):
            if status == "signed":
                label = " Đã ký"
                icon  = _load_icon("action_check_128.png")
                color = "#16a34a"
            elif status == "draft":
                label = " Nháp"
                icon  = _load_icon("action_warning_128.png")
                color = "#6b7280"
            else:  # pending / chờ ký
                label = " Chờ ký"
                icon  = _load_icon("action_warning_128.png")
                color = "#ea580c"
            btn = QPushButton(label)
            btn.setIcon(icon)
            btn.setStyleSheet(
                f"color: {color}; font-weight: bold; background: transparent;"
                " border: none; text-align: left; padding-left: 10px;"
            )
            btn.setCursor(Qt.CursorShape.PointingHandCursor)
            return btn

        try:
            from core.contracts_db import init_db
            init_db()  # Tự động đồng bộ schema và bổ sung bảng còn thiếu (như signature_cache)
            with get_connection() as conn:
                rows = conn.execute(
                    f"SELECT tb.id, tb.name_file, tb.name, tb.party_a, tb.party_b, tb.date, tb.path, tb.sign_sequence, tb.mst_a, tb.mst_b, tb.note, tb.amount_total, tb.modified_at, sc.is_valid, sc.is_signed, sc.mst_list "
                    f"FROM {db_table} tb "
                    f"LEFT JOIN signature_cache sc ON tb.path = sc.path "
                    f"ORDER BY "
                    f"  CASE WHEN tb.sign_sequence = '' AND (tb.path LIKE '%.docx' OR tb.path = '') THEN 0 ELSE 1 END ASC, "
                    f"  tb.date DESC"
                ).fetchall()

            contracts = [dict(r) for r in rows]

            # Tính stats
            stats_data = {"total": 0, "signed": 0, "pending": 0, "draft": 0}
            for c in contracts:
                c_date = c.get("date", "")
                if sel_year and not c_date.startswith(sel_year):
                    continue
                stats_data["total"] += 1
                
                seq  = c.get("sign_sequence", "")
                path = c.get("path", "")
                a_st, b_st = status_from_sequence(seq)
                if not seq and path.lower().endswith(".pdf"):
                    a_st = "pending"
                    b_st = "pending"
                    
                if a_st == "signed" and b_st == "signed":
                    stats_data["signed"] += 1
                elif a_st == "draft" and b_st == "draft":
                    stats_data["draft"] += 1
                else:
                    stats_data["pending"] += 1

            # Cập nhật mini dashboard
            try:
                if category == "sale":
                    st_widgets = self.stats_sale
                elif category == "purchase":
                    st_widgets = self.stats_purchase
                else:
                    st_widgets = self.stats_other
                for widget, val in [
                    (st_widgets[0], str(stats_data["total"])),
                    (st_widgets[1], str(stats_data["signed"])),
                    (st_widgets[2], str(stats_data["pending"])),
                    (st_widgets[3], str(stats_data["draft"])),
                ]:
                    cnt_lbl = widget.findChildren(type(QLabel()))[0]
                    cnt_lbl.setText(val)
            except Exception:
                pass

            filtered_contracts = []
            for c in contracts:
                c_date = c.get("date", "")
                if sel_year and not c_date.startswith(sel_year):
                    continue

                # Cập nhật tổng thể theo trạng thái cho lọc
                seq  = c.get("sign_sequence", "")
                path = c.get("path", "")
                a_st, b_st = status_from_sequence(seq)

                if not seq and path.lower().endswith(".pdf"):
                    a_st = "pending"
                    b_st = "pending"

                if a_st == "signed" and b_st == "signed":
                    overall = "signed"
                elif a_st == "draft" and b_st == "draft":
                    overall = "draft"
                else:
                    overall = "pending"

                if sel_status != "all" and overall != sel_status:
                    continue

                if search_text:
                    name_file = str(c.get("name_file", "")).lower()
                    name_p = str(
                        (c.get("party_a") if category == "sale" else c.get("party_b"))
                        or c.get("party_a")
                        or c.get("party_b")
                        or c.get("name", "")
                    ).lower()
                    contract_title = str(c.get("name", "")).lower()
                    note_text = str(c.get("note", "")).lower()
                    mst_a     = str(c.get("mst_a", "")).lower()
                    mst_b     = str(c.get("mst_b", "")).lower()
                    if (search_text not in name_file and 
                        search_text not in name_p and 
                        search_text not in contract_title and
                        search_text not in note_text and
                        search_text not in mst_a and 
                        search_text not in mst_b):
                        continue

                filtered_contracts.append((c, a_st, b_st))

            # Xử lý Phân Trang
            ROWS_PER_PAGE = 30
            total_filtered = len(filtered_contracts)
            max_pages = max(1, (total_filtered + ROWS_PER_PAGE - 1) // ROWS_PER_PAGE)
            
            if category == "sale":
                curr_page = self.current_page_sale
            elif category == "purchase":
                curr_page = self.current_page_purchase
            else:
                curr_page = self.current_page_other
            if curr_page < 1: curr_page = 1
            if curr_page > max_pages: curr_page = max_pages
            
            if category == "sale": 
                self.current_page_sale = curr_page
                self.page_lbl_sale.setText(f"Trang {curr_page} / {max_pages}")
            elif category == "purchase":
                self.current_page_purchase = curr_page
                self.page_lbl_purchase.setText(f"Trang {curr_page} / {max_pages}")
            else:
                self.current_page_other = curr_page
                self.page_lbl_other.setText(f"Trang {curr_page} / {max_pages}")

            start_idx = (curr_page - 1) * ROWS_PER_PAGE
            page_data = filtered_contracts[start_idx:start_idx + ROWS_PER_PAGE]

            for c, a_st, b_st in page_data:
                c_date = c.get("date", "")
                seq = c.get("sign_sequence", "")

                row = table.rowCount()
                table.insertRow(row)

                # Col 0: Checkbox
                chk_item = QTableWidgetItem()
                chk_item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
                chk_item.setCheckState(Qt.CheckState.Unchecked)
                chk_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
                table.setItem(row, 0, chk_item)

                # Col 1: Ngày ký (format yyyy-mm-dd)
                table.setItem(row, 1, QTableWidgetItem(c_date))

                # Col 2: Số HĐ (tên gốc file không suffix ký)
                table.setItem(row, 2, QTableWidgetItem(c.get("name_file", "")))

                # Col 3 & 4: MST, Col 5 & 6: Trạng thái ký
                is_signed = c.get("is_signed", 0)
                is_valid = c.get("is_valid", 0)
                mst_list = c.get("mst_list", "")
                
                def build_btn(st, expected_mst):
                    btn = make_status_btn(st)
                    if st == "signed":
                        msts = mst_list.split(",") if mst_list else []
                        mst_clean = [m.strip().lower() for m in msts if m.strip()]
                        target = str(expected_mst).strip().lower() if expected_mst else ""
                        
                        if is_signed and not is_valid:
                            btn.setText(" Lỗi chữ ký")
                            btn.setStyleSheet("color: #ef4444; font-weight: bold; background: transparent; border: none; text-align: left; padding-left: 10px;")
                            btn.setIcon(_load_icon("action_error_128.png"))
                            if mst_list:
                                btn.setToolTip(f"Chữ ký bị đổi hoặc bị lỗi xác minh.\nMST đã ký: {mst_list}")
                            else:
                                btn.setToolTip("Chữ ký PDF không nguyên vẹn hoặc bị mất/sửa đổi!")
                        else:
                            if target and mst_clean and target not in mst_clean:
                                btn.setText(" Lỗi MST")
                                btn.setStyleSheet("color: #ef4444; font-weight: bold; background: transparent; border: none; text-align: left; padding-left: 10px;")
                                btn.setIcon(_load_icon("action_warning_128.png"))
                                btn.setToolTip(f"Cảnh báo: Ký bởi MST ({mst_list}) KHÔNG KHỚP với MST cài đặt ({target})!")
                            elif mst_list:
                                btn.setToolTip(f"Chữ ký hợp lệ. Ký bởi MST: {mst_list}")
                    return btn

                if category == "sale":
                    table.setItem(row, 3, QTableWidgetItem(c.get("mst_b", "")))
                    table.setItem(row, 4, QTableWidgetItem(c.get("mst_a", "")))
                    table.setCellWidget(row, 5, build_btn(b_st, c.get("mst_b", "")))  # Bên B = công ty
                    table.setCellWidget(row, 6, build_btn(a_st, c.get("mst_a", "")))  # Bên A = khách
                else:
                    table.setItem(row, 3, QTableWidgetItem(c.get("mst_a", "")))
                    table.setItem(row, 4, QTableWidgetItem(c.get("mst_b", "")))
                    table.setCellWidget(row, 5, build_btn(a_st, c.get("mst_a", "")))  # Bên A = công ty
                    table.setCellWidget(row, 6, build_btn(b_st, c.get("mst_b", "")))  # Bên B = đối tác

                # Col 7: Thao tác — truyền dict khớp với các handler hiện tại
                c_item = {
                    "id":   c.get("id", ""),           # INTEGER PK
                    "name_file": c.get("name_file", ""),  # tên gốc file
                    "path": c.get("path", ""),
                    "name": c.get("name", ""),
                    "note": c.get("note", ""),
                    "date": c_date,
                    "sign_sequence": seq,
                    "party_a": c.get("party_a", ""),
                    "party_b": c.get("party_b", ""),
                    "amount_total": c.get("amount_total", 0),
                    "contract_category": category,
                }
                actions_w = QWidget()
                actions_l = QHBoxLayout(actions_w)
                actions_l.setContentsMargins(4, 4, 4, 4)
                actions_l.setSpacing(6)

                btn_view   = IconButton(_load_icon("action_view_128.png"),   tooltip="Xem hợp đồng")
                btn_mail   = IconButton(_load_icon("action_mail_128.png"),   tooltip="Gửi Mail")
                btn_share  = IconButton(_load_icon("action_share_128.png"),  tooltip="Share Link Drive")
                btn_delete = IconButton(_load_icon("action_delete_128.png"), tooltip="Xóa hợp đồng")

                btn_view.clicked.connect(
                    lambda _, item=c_item: self._on_view_click(item, category))
                if self._contract_mail_key(c_item, category) in self._mail_sent_keys:
                    self._apply_mail_sent_style(btn_mail)

                btn_mail.clicked.connect(
                    lambda _, item=c_item, btn=btn_mail: self._on_mail_click(item, category, btn))
                btn_share.clicked.connect(
                    lambda _, item=c_item: self._on_share_click(item, category))
                btn_delete.clicked.connect(
                    lambda _, item=c_item, cat=category: self._on_delete_click(item, cat))

                actions_l.addWidget(btn_view)
                actions_l.addWidget(btn_mail)
                actions_l.addWidget(btn_share)
                actions_l.addWidget(btn_delete)
                table.setCellWidget(row, 7, actions_w)

        except Exception as e:
            import traceback
            print(f"[_reload_table] Error: {e}\n{traceback.format_exc()}")


    def _on_view_click(self, contract_item: dict, category: str):
        """Mở xem file hợp đồng PDF bằng tab Công Cụ PDF."""
        import os
        from PySide6.QtWidgets import QMessageBox
        c_path = contract_item.get("path")
        if c_path:
            abs_path = os.path.abspath(get_company_root_path(c_path))
            if os.path.exists(abs_path):
                try:
                    self._open_pdf_in_pdf_tools(abs_path)
                except Exception as e:
                    QMessageBox.warning(self.window(), "Lỗi Mở File", f"Không thể mở file: {e}")
            else:
                QMessageBox.warning(self.window(), "Lỗi", f"Không tìm thấy file hợp đồng PDF trên đĩa cứng:\n{abs_path}")
        else:
            QMessageBox.warning(self.window(), "Lỗi", "Bản ghi không chứa thông tin đường dẫn file hợp đồng!")

    def _on_delete_click(self, contract_item: dict, category: str):
        """Xóa hợp đồng khỏi danh sách và file liên quan trên đĩa."""
        import os
        from PySide6.QtWidgets import QMessageBox
        from core.drive_sync import _ROOT
        from core.contracts_db import get_connection
        
        c_id   = contract_item.get("id")
        c_name = contract_item.get("name_file", "")
        c_date = contract_item.get("date", "")
        
        if not c_name:
            QMessageBox.warning(self.window(), "Lỗi", "Hợp đồng không hợp lệ (thiếu name_file).")
            return

        # ── Quy tụ tất cả các file liên đới ──────────────────────────────────
        cat_dir  = self._category_dir_name(category)
        data_dir = get_company_root_path(os.path.join("contracts", cat_dir, "data", c_date))
        
        # Tìm tất cả file trong pending/completed bắt đầu bằng c_name
        candidates = []
        if os.path.isdir(data_dir):
            for sub_dir in ("pending", "completed"):
                sub_path = os.path.join(data_dir, sub_dir)
                if os.path.isdir(sub_path):
                    for fname in os.listdir(sub_path):
                        if fname.startswith(c_name) and fname.endswith((".pdf", ".docx")):
                            candidates.append(os.path.join(sub_path, fname))

        existing_files = [p for p in candidates if os.path.exists(p)]

        # ── Hỏi xác nhận ────────────────────────────────────────────────────
        file_list_str = ""
        if existing_files:
            file_list_str = "\n\nFile sẽ bị xóa:\n" + "\n".join(
                f"  • {os.path.basename(p)}" for p in existing_files
            )

        reply = QMessageBox.question(
            self.window(),
            "Xác nhận Xóa",
            f"Bạn có chắc chắn muốn xóa hợp đồng {c_name} không?{file_list_str}",
            QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
            QMessageBox.StandardButton.No
        )
        if reply != QMessageBox.StandardButton.Yes:
            return

        # ── Xóa file trên đĩa / Chuyển vào thùng rác ──────────────────────
        deleted_files = []
        from core.utils import move_to_trash
        for path in existing_files:
            try:
                move_to_trash(path)
                deleted_files.append(os.path.basename(path))
                print(f"[Cleanup] Đã di chuyển vào thùng rác: {path}")
            except Exception as e:
                print(f"[Warn] Không di chuyển được thùng rác cho {path}: {e}")

        # ── Xóa khỏi SQLite DB ─────────────────────────────────────────────
        table = self._category_table_name(category)
        try:
            with get_connection() as conn:
                conn.execute(f"DELETE FROM {table} WHERE id = ?", (c_id,))
                conn.commit()

            msg = f"✅ Đã xóa hợp đồng {c_name}"
            if deleted_files:
                msg += f" + {len(deleted_files)} file"
            
            show_toast(self.window(), msg)
            
            # Quét & tải lại
            from core.contracts_db import scan_contract_files
            scan_contract_files(category, full_refresh=True)
            self._reload_table(category)
        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi", f"Không thể xóa hợp đồng trong DB: {e}")


    def _on_mail_click(self, contract_item: dict, category: str, btn_mail=None):
        """Mở form gửi email hợp đồng với file đính kèm PDF."""
        import os, json
        from core.drive_sync import _ROOT
        from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
                                     QLineEdit, QTextEdit, QPushButton, QFileDialog,
                                     QGridLayout, QFrame)
        from PySide6.QtCore import Qt

        c_name = contract_item.get("name_file", "")
        c_date = contract_item.get("date", "")
        partner = (
            contract_item.get("party_a") or contract_item.get("party_b") or ""
            if category == "sale"
            else contract_item.get("party_b") or contract_item.get("party_a") or ""
        )
        partner_email = (
            contract_item.get("email_partner")
            or contract_item.get("email")
            or contract_item.get("partner_email")
            or ""
        )

        # ── Tìm file PDF đính kèm (ưu tiên: _bky_aky > _bky > _aky > pending PDF) ────
        cat_dir  = self._category_dir_name(category)
        data_dir = get_company_root_path(os.path.join("contracts", cat_dir, "data", c_date))
        
        candidates = []
        if os.path.isdir(data_dir):
            for sub_dir in ("completed", "pending"):
                sub_path = os.path.join(data_dir, sub_dir)
                if os.path.isdir(sub_path):
                    for fname in os.listdir(sub_path):
                        if fname.startswith(c_name) and fname.endswith(".pdf"):
                            candidates.append(os.path.join(sub_path, fname))

        # Fallback: nếu thiếu date trong DB thì dò mọi ngày để không mất file khi gửi.
        if not candidates:
            base_contracts = get_company_root_path(os.path.join("contracts", cat_dir, "data"))
            if os.path.isdir(base_contracts):
                for dsub in os.listdir(base_contracts):
                    dpath = os.path.join(base_contracts, dsub)
                    if not os.path.isdir(dpath):
                        continue
                    for sub_dir in ("completed", "pending"):
                        sub_path = os.path.join(dpath, sub_dir)
                        if os.path.isdir(sub_path):
                            for fname in os.listdir(sub_path):
                                if fname.startswith(c_name) and fname.endswith(".pdf"):
                                    candidates.append(os.path.join(sub_path, fname))
                                    if not c_date:
                                        c_date = dsub
        
        # Sort candidates to prefer completed over pending, and longer suffixes over shorter
        candidates.sort(key=lambda p: (0 if "completed" in p else 1, -len(os.path.basename(p))))
        attachment_path = candidates[0] if candidates else ""

        # ── Đọc config email từ settings.json (đúng theo app-data/runtime) ──
        from core.utils import get_app_data_path
        settings_path = get_app_data_path("settings.json")

        sender_email = ""
        smtp_server  = "smtp.gmail.com"
        smtp_port    = 465
        smtp_pass    = ""
        our_company  = ""
        try:
            with open(settings_path, "r", encoding="utf-8") as f:
                info = json.load(f).get("company", {})
            m = info.get("mail_config", {})
            sender_email = m.get("user", "")
            smtp_pass    = m.get("pass", "")
            smtp_server  = m.get("smtp_server", "smtp.gmail.com")
            smtp_port    = int(m.get("smtp_port", 465))
            our_company  = info.get("legal_name", "")
        except Exception:
            pass

        # ── Xây dựng dialog ──────────────────────────────────────────────────
        dlg = QDialog(self.window())
        dlg.setWindowTitle(f"📧 Gửi Email Hợp Đồng – {c_name}")
        dlg.setMinimumWidth(580)
        dlg.setModal(True)

        layout = QVBoxLayout(dlg)
        layout.setSpacing(12)
        layout.setContentsMargins(20, 20, 20, 20)

        grid = QGridLayout()
        grid.setColumnStretch(1, 1)
        grid.setVerticalSpacing(8)

        def add_row(row, label_text, widget):
            lbl = QLabel(label_text)
            lbl.setStyleSheet("font-weight: bold; color: #374151;")
            grid.addWidget(lbl, row, 0, Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignRight)
            grid.addWidget(widget, row, 1)

        # Người nhận
        inp_to = QLineEdit()
        inp_to.setPlaceholderText("example@email.com")
        inp_to.setText(partner_email)
        add_row(0, "Email nhận:", inp_to)

        # Tiêu đề
        inp_subject = QLineEdit()
        inp_subject.setText(f"[Hợp Đồng] {c_name} – {partner}")
        add_row(1, "Tiêu đề:", inp_subject)

        # Nội dung
        inp_body = QTextEdit()
        inp_body.setMinimumHeight(160)

        # ── Tính att_stem và return_name từ file đính kèm ──────────────────
        import re as _re3
        _STRIP_SFX = _re3.compile(
            r'[_ ](aky|bky|aky[_ ]bky|bky[_ ]aky|daky|asign|bsign|signed?).*$',
            _re3.IGNORECASE)
        pdf_name_clean = _STRIP_SFX.sub('', c_name)  # tên gốc không suffix
        if attachment_path:
            att_stem = os.path.splitext(os.path.basename(attachment_path))[0]
        else:
            att_stem = pdf_name_clean
        # Detect both_signed từ tên file (tin cậy hơn sign_sequence)
        _has_aky2 = bool(_re3.search(r'[_]aky', att_stem, _re3.IGNORECASE))
        _has_bky2 = bool(_re3.search(r'[_]bky', att_stem, _re3.IGNORECASE))
        both_signed = _has_aky2 and _has_bky2
        if both_signed:
            return_name = att_stem
        elif category == "sale":
            return_name = f"{att_stem}_aky"
        else:
            return_name = f"{att_stem}_bky"

        # Đọc nội dung email từ cài đặt (nếu có)
        saved_body = info.get("mail_body", "") if 'info' in dir() else ""
        guide_block = build_contract_software_guide_block()
        if not saved_body:
            saved_body = (
                "Kính gửi Quý đối tác {partner},\n\n"
                "Công ty chúng tôi ({our_company}) xin gửi đến Quý đối tác "
                "hợp đồng số {c_id} đính kèm theo email này.\n\n"
                "Quý đối tác vui lòng xem xét, ký và gửi lại bản đã ký cho chúng tôi.\n\n"
                f"{guide_block}\n\n"
                "📎 Lưu ý khi gửi lại:\n"
                "  • Khi ký xong, vui lòng đặt tên file: {return_name}.pdf\n"
                "  • Gửi reply email này hoặc gửi về địa chỉ email của chúng tôi.\n\n"
                "🚀 Giới thiệu thêm:\n"
                "Nếu Quý đối tác cần triển khai phần mềm kế toán/thuế cho HKD, "
                "quản lý hợp đồng ký số, đồng bộ Google Drive và báo cáo tự động, "
                "chúng tôi sẵn sàng hỗ trợ demo mở rộng theo nhu cầu thực tế.\n\n"
                "Trân trọng,\n{our_company}"
            )
        elif not both_signed:
            saved_body = self._ensure_mail_software_guide(saved_body)
        try:
            body_default = saved_body.format(
                partner=partner or "Quý đối tác",
                c_name=c_name,
                c_id=pdf_name_clean,
                our_company=our_company,
                att_stem=att_stem,
                return_name=return_name,
            )
        except Exception:
            # Nếu format lỗi, thay thế thủ công
            body_default = (
                saved_body
                .replace("{partner}",     partner or "Quý đối tác")
                .replace("{our_company}", our_company)
                .replace("{c_id}",        pdf_name_clean)
                .replace("{c_name}",      c_name)
                .replace("{att_stem}",    att_stem)
                .replace("{return_name}", return_name)
            )
        inp_body.setPlainText(body_default)
        add_row(2, "Nội dung:", inp_body)

        # File đính kèm
        attach_row = QHBoxLayout()
        inp_attach = QLineEdit(attachment_path)
        inp_attach.setReadOnly(True)
        inp_attach.setPlaceholderText("(Chưa chọn file)")
        inp_attach.setStyleSheet("color: #374151; background: #f9fafb;")
        btn_browse_attach = QPushButton("📂 Chọn")
        btn_browse_attach.setFixedWidth(80)
        def on_browse_attach():
            p, _ = QFileDialog.getOpenFileName(dlg, "Chọn file đính kèm", os.path.dirname(attachment_path) if attachment_path else "", "PDF Files (*.pdf)")
            if p:
                inp_attach.setText(p)
        btn_browse_attach.clicked.connect(on_browse_attach)
        attach_row.addWidget(inp_attach)
        attach_row.addWidget(btn_browse_attach)

        lbl_attach = QLabel("File đính kèm:")
        lbl_attach.setStyleSheet("font-weight: bold; color: #374151;")
        grid.addWidget(lbl_attach, 3, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
        attach_widget = QFrame()
        attach_widget.setLayout(attach_row)
        grid.addWidget(attach_widget, 3, 1)

        layout.addLayout(grid)

        guide_attachment_path = self._get_contract_guide_path()
        guide_note = QLabel(
            f"📎 Email sẽ tự đính kèm thêm file hướng dẫn {CONTRACT_GUIDE_FILENAME}."
            if guide_attachment_path
            else f"⚠️ Không tìm thấy file hướng dẫn {CONTRACT_GUIDE_FILENAME} để đính kèm email."
        )
        guide_note.setWordWrap(True)
        guide_note.setStyleSheet(
            "color: #16a34a; font-size: 11px;"
            if guide_attachment_path
            else "color: #dc2626; font-size: 11px;"
        )
        layout.addWidget(guide_note)

        # Ghi chú
        note = QLabel(f"📤 Gửi từ: <b>{sender_email}</b>  |  SMTP: {smtp_server}:{smtp_port}")
        note.setStyleSheet("color: #6b7280; font-size: 11px;")
        layout.addWidget(note)

        # Separator
        sep = QFrame(); sep.setFrameShape(QFrame.Shape.HLine)
        sep.setStyleSheet("color: #e5e7eb;")
        layout.addWidget(sep)

        # Buttons
        btn_row = QHBoxLayout()
        btn_row.addStretch()
        btn_cancel = QPushButton("Hủy")
        btn_cancel.setFixedWidth(90)
        btn_cancel.clicked.connect(dlg.reject)
        btn_send = QPushButton("Gửi Email")
        btn_send.setFixedWidth(120)
        btn_send.setStyleSheet("background: #2563eb; color: white; font-weight: bold; border-radius: 6px; padding: 6px 12px;")
        btn_row.addWidget(btn_cancel)
        btn_row.addWidget(btn_send)
        layout.addLayout(btn_row)

        # ── Hàm gửi ─────────────────────────────────────────────────────────
        def do_send():
            to_addr = inp_to.text().strip()
            subject = inp_subject.text().strip()
            body    = inp_body.toPlainText().strip()
            attach  = inp_attach.text().strip()

            if not to_addr:
                QMessageBox.warning(dlg, "Thiếu thông tin", "Vui lòng nhập địa chỉ email nhận.")
                return
            if not sender_email or not smtp_pass:
                QMessageBox.critical(dlg, "Chưa cấu hình", "Chưa có cấu hình Email trong Cài đặt → Email (IMAP/SMTP).")
                return

            try:
                import smtplib
                from email.mime.multipart import MIMEMultipart
                from email.mime.text import MIMEText
                from email.mime.base import MIMEBase
                from email import encoders

                msg = MIMEMultipart()
                msg["From"]    = sender_email
                msg["To"]      = to_addr
                msg["Subject"] = subject

                msg.attach(MIMEText(body, "plain", "utf-8"))

                if attach and os.path.exists(attach):
                    # Chỉ gửi duy nhất 1 file PDF do người dùng chọn.
                    if not attach.lower().endswith(".pdf"):
                        raise ValueError("File đính kèm phải là PDF (.pdf).")
                    with open(attach, "rb") as fh:
                        part = MIMEBase("application", "octet-stream")
                        part.set_payload(fh.read())
                    encoders.encode_base64(part)
                    part.add_header("Content-Disposition",
                                    f'attachment; filename="{os.path.basename(attach)}"')
                    msg.attach(part)

                if not guide_attachment_path or not os.path.exists(guide_attachment_path):
                    raise FileNotFoundError(
                        f"Không tìm thấy file hướng dẫn {CONTRACT_GUIDE_FILENAME} để đính kèm email."
                    )
                with open(guide_attachment_path, "rb") as fh:
                    guide_part = MIMEBase("application", "octet-stream")
                    guide_part.set_payload(fh.read())
                encoders.encode_base64(guide_part)
                guide_part.add_header(
                    "Content-Disposition",
                    f'attachment; filename="{CONTRACT_GUIDE_FILENAME}"'
                )
                msg.attach(guide_part)

                btn_send.setEnabled(False)
                btn_send.setText("Đang gửi...")

                if smtp_port == 465:
                    import ssl
                    ctx = ssl.create_default_context()
                    with smtplib.SMTP_SSL(smtp_server, smtp_port, context=ctx) as sv:
                        sv.login(sender_email, smtp_pass)
                        sv.sendmail(sender_email, to_addr, msg.as_bytes())
                else:
                    with smtplib.SMTP(smtp_server, smtp_port) as sv:
                        sv.starttls()
                        sv.login(sender_email, smtp_pass)
                        sv.sendmail(sender_email, to_addr, msg.as_bytes())

                dlg.accept()
                show_toast(self.window(), f"✅ Đã gửi email hợp đồng {c_name} đến {to_addr}")

                key = self._contract_mail_key(contract_item, category)
                self._mail_sent_keys.add(key)
                if btn_mail is not None:
                    self._apply_mail_sent_style(btn_mail)
            except Exception as e:
                btn_send.setEnabled(True)
                btn_send.setText("Gửi Email")
                QMessageBox.critical(dlg, "Lỗi gửi mail", str(e))

        btn_send.clicked.connect(do_send)
        dlg.exec()


    def _on_share_click(self, contract_item: dict, category: str):
        """Upload PDF hợp đồng lên Drive, tạo link public, hiện dialog gửi khách."""
        if not self._ensure_plan_feature("has_backup_sync", "Share Link Drive", "STARTER, PRO hoặc OFFICE"):
            return

        import os
        from PySide6.QtWidgets import (QDialog, QVBoxLayout, QLabel,
                                     QPushButton, QTextEdit, QApplication,
                                     QMessageBox, QProgressDialog)
        from PySide6.QtCore import Qt, QUrl, QThread, Signal
        from PySide6.QtGui import QDesktopServices, QFont

        c_name   = contract_item.get("name_file", "")
        date_str = contract_item.get("date", "")
        partner = (
            contract_item.get("partner")
            or (contract_item.get("party_a") if category == "sale" else contract_item.get("party_b"))
            or contract_item.get("party_a")
            or contract_item.get("party_b")
            or "Quý đối tác"
        )

        # Tìm file PDF local
        from core.drive_sync import _ROOT
        cat_dir  = self._category_dir_name(category)
        data_dir = get_company_root_path(os.path.join("contracts", cat_dir, "data", date_str))
        
        candidates = []
        if os.path.isdir(data_dir):
            for sub_dir in ("completed", "pending"):
                sub_path = os.path.join(data_dir, sub_dir)
                if os.path.isdir(sub_path):
                    for fname in os.listdir(sub_path):
                        if fname.startswith(c_name) and fname.endswith(".pdf"):
                            candidates.append(os.path.join(sub_path, fname))

        # Fallback: dò mọi ngày nếu date trống để đảm bảo luôn tìm đúng file cần share.
        if not candidates:
            base_contracts = get_company_root_path(os.path.join("contracts", cat_dir, "data"))
            if os.path.isdir(base_contracts):
                for dsub in os.listdir(base_contracts):
                    dpath = os.path.join(base_contracts, dsub)
                    if not os.path.isdir(dpath):
                        continue
                    for sub_dir in ("completed", "pending"):
                        sub_path = os.path.join(dpath, sub_dir)
                        if os.path.isdir(sub_path):
                            for fname in os.listdir(sub_path):
                                if fname.startswith(c_name) and fname.endswith(".pdf"):
                                    candidates.append(os.path.join(sub_path, fname))
                                    if not date_str:
                                        date_str = dsub
        
        candidates.sort(key=lambda p: (0 if "completed" in p else 1, -len(os.path.basename(p))))
        pdf_path = candidates[0] if candidates else ""

        if not pdf_path:
            QMessageBox.warning(self.window(), "Không tìm thấy file",
                                f"Không tìm thấy file PDF hợp đồng {c_name} trên máy.")
            return

        # ── Upload trong background thread ──────────────────────────────────
        class UploadThread(QThread):
            done = Signal(dict)
            def __init__(self, cat, date, cname, path):
                super().__init__()
                self._cat, self._date, self._cname, self._path = cat, date, cname, path
            def run(self):
                try:
                    from core.drive_sync import upload_contract_pdf_to_drive
                    result = upload_contract_pdf_to_drive(
                        self._cat, self._date, self._cname, self._path)
                except Exception as e:
                    result = {"success": False, "message": str(e)}
                self.done.emit(result)

        prog = QProgressDialog(f"Đang upload {os.path.basename(pdf_path)} lên Drive...",
                               None, 0, 0, self.window())
        prog.setWindowModality(Qt.WindowModality.WindowModal)
        prog.setWindowTitle("Uploading…")
        prog.show()

        thread = UploadThread(category, date_str, c_name, pdf_path)
        self._upload_thread = thread  # giữ reference, tránh GC destroy thread sớm

        def on_done(result):
            prog.close()
            drive_link = result.get("link", "")

            if not result.get("success"):
                QMessageBox.critical(self.window(), "Lỗi Upload",
                    f"Không thể upload lên Drive:\n{result.get('message', '')}")
                return

            # ── Dialog hiển thị link + hướng dẫn ký số ──────────────────
            dlg = QDialog(self.window())
            dlg.setWindowTitle(f"Link Hợp đồng {c_name}")
            dlg.setMinimumWidth(600)
            dlg.setMinimumHeight(520)
            layout = QVBoxLayout(dlg)
            layout.setSpacing(10)
            layout.setContentsMargins(20, 16, 20, 14)

            from PySide6.QtWidgets import QHBoxLayout, QSizePolicy

            lbl_head = QLabel(f"✅ Upload thành công — Hợp đồng <b>{c_name}</b>")
            lbl_head.setTextFormat(Qt.TextFormat.RichText)
            lbl_head.setStyleSheet("font-size:14px; color:#16a34a; font-weight:bold;")
            layout.addWidget(lbl_head)

            # ── 1 khung dọc (Hướng dẫn -> Link) ──────────────────────────────
            # Hướng dẫn ký số (Trên)
            lbl_guide_title = QLabel("📝 Hướng dẫn ký số gửi kèm khách hàng:")
            lbl_guide_title.setStyleSheet("font-weight:bold; margin-top:8px;")
            layout.addWidget(lbl_guide_title)

            # Xác định trạng thái ký hiện tại từ tên file để hướng dẫn tên file gửi lại đúng quy tắc.
            import re as _re_sfx
            stem_name = os.path.splitext(os.path.basename(pdf_path))[0]
            has_aky = bool(_re_sfx.search(r"_aky", stem_name, _re_sfx.IGNORECASE))
            has_bky = bool(_re_sfx.search(r"_bky", stem_name, _re_sfx.IGNORECASE))
            both_signed = has_aky and has_bky

            if both_signed:
                return_name = stem_name
            elif category == "sale":
                # HĐ bán hàng: đối tác là Bên A.
                # - Chưa ký gì: đối tác ký trước => _aky
                # - Đã có _bky (mình ký): đối tác ký bổ sung => _bky_aky
                return_name = f"{stem_name}_aky"
            else:
                # HĐ mua hàng: đối tác là Bên B.
                return_name = f"{stem_name}_bky"
            
            if both_signed:
                guide_text = (
                    f"Kính gửi {partner},\n\n"
                    f"Chúng tôi gửi hợp đồng {c_name} đã có đủ chữ ký của các bên.\n\n"
                    f"🔧 Nếu cần cài phần mềm ký PDF:\n"
                    f"  • Adobe Acrobat Reader: https://get.adobe.com/reader/\n"
                    f"  • Hướng dẫn ký số: https://helpx.adobe.com/acrobat/using/signing-pdfs.html\n\n"
                    f"🚀 Nhu cầu mở rộng hệ thống kế toán/thuế + hợp đồng số: "
                    f"chúng tôi hỗ trợ triển khai theo quy trình thực tế của doanh nghiệp.\n\n"
                    f"🔗 Link hợp đồng: {drive_link}\n"
                )
            else:
                guide_text = (
                    f"Kính gửi {partner},\n\n"
                    f"Chúng tôi gửi đến Quý đối tác hợp đồng {c_name} qua link Google Drive bên dưới.\n"
                    f"Vui lòng mở link, tải file PDF về và thực hiện ký số theo các bước:\n\n"
                    f"  1. Mở file PDF bằng Adobe Acrobat Reader.\n\n"
                    f"  2. Chọn mục Ký số / Digital Signature / Sign Document.\n\n"
                    f"  3. Chọn chứng thư số (USB Token hoặc chữ ký trên MySign, ViettelCA, BKAV…).\n\n"
                    f"  4. Xác nhận thông tin và lưu file đã ký.\n\n"
                    f"  5. Gửi lại file đã ký theo đúng tên: {return_name}.pdf\n"
                    f"     (HĐ bán hàng: ưu tiên quy tắc _bky_aky hoặc _aky theo trạng thái hiện tại).\n\n"
                    f"🔧 Phần mềm ký PDF đề xuất: Adobe Acrobat Reader\n"
                    f"  • Tải phần mềm: https://get.adobe.com/reader/\n"
                    f"  • Hướng dẫn ký số: https://helpx.adobe.com/acrobat/using/signing-pdfs.html\n\n"
                    f"🚀 Nếu Quý đối tác cần mở rộng phần mềm kế toán/thuế, "
                    f"quản lý hợp đồng ký số và đồng bộ dữ liệu, "
                    f"chúng tôi sẵn sàng hỗ trợ demo triển khai.\n\n"
                    f"🔗 Link hợp đồng: {drive_link}"
                )
            try:
                import json as _json_share
                import re as _re_share

                settings_path = get_app_data_path("settings.json")
                if os.path.exists(settings_path):
                    with open(settings_path, "r", encoding="utf-8") as sf:
                        share_info = _json_share.load(sf).get("company", {})
                else:
                    share_info = {}

                our_company = share_info.get("legal_name", "") or "Công ty chúng tôi"
                mail_tpl = (share_info.get("mail_body", "") or "").strip()
                if not mail_tpl:
                    guide_block = build_contract_software_guide_block()
                    mail_tpl = (
                        "Kính gửi Quý đối tác {partner},\n\n"
                        "Công ty chúng tôi ({our_company}) xin gửi đến Quý đối tác "
                        "hợp đồng số {c_id} qua link Google Drive bên dưới.\n\n"
                        "Quý đối tác vui lòng xem xét, ký và gửi lại bản đã ký cho chúng tôi.\n\n"
                        f"{guide_block}\n\n"
                        "Lưu ý khi gửi lại:\n"
                        "  • Khi ký xong, vui lòng đặt tên file: {return_name}.pdf\n"
                        "  • Gửi reply email này hoặc gửi về địa chỉ email của chúng tôi.\n\n"
                        "Link hợp đồng: {drive_link}\n\n"
                        "Trân trọng,\n{our_company}"
                    )
                elif not both_signed:
                    mail_tpl = self._ensure_mail_software_guide(mail_tpl)

                strip_suffix = _re_share.compile(
                    r'[_ ](aky|bky|aky[_ ]bky|bky[_ ]aky|daky|asign|bsign|signed?).*$',
                    _re_share.IGNORECASE,
                )
                c_id_clean = strip_suffix.sub("", c_name or stem_name)
                format_values = {
                    "partner": partner or "Quý đối tác",
                    "c_name": c_name,
                    "c_id": c_id_clean,
                    "our_company": our_company,
                    "att_stem": stem_name,
                    "return_name": return_name,
                    "drive_link": drive_link,
                }
                try:
                    guide_text = mail_tpl.format(**format_values)
                except Exception:
                    guide_text = mail_tpl
                    for key, value in format_values.items():
                        guide_text = guide_text.replace("{" + key + "}", str(value))

                if drive_link and drive_link not in guide_text:
                    guide_text = guide_text.rstrip() + f"\n\nLink hợp đồng: {drive_link}"
            except Exception as exc:
                print(f"[Share] Không render được nội dung email, dùng nội dung mặc định: {exc}")

            guide_box = QTextEdit()
            guide_box.setPlainText(guide_text)
            guide_box.setReadOnly(False)
            guide_box.setStyleSheet(
                "background:#f8fafc; border:1px solid #cbd5e1; border-radius:6px;"
                "font-size:12px; padding:8px; line-height: 1.4;"
            )
            guide_box.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
            layout.addWidget(guide_box)

            # ── 2 nút trên 1 dòng (50/50) ────────────────────────────────
            btn_row = QHBoxLayout()
            btn_row.setSpacing(10)

            btn_copy_all = QPushButton("📋  Copy link + hướng dẫn")
            btn_copy_all.setStyleSheet(
                "background:#16a34a;color:white;font-weight:bold;"
                "border-radius:8px;padding:10px 0;font-size:13px;")
            btn_copy_all.setCursor(Qt.CursorShape.PointingHandCursor)
            btn_copy_all.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
            btn_copy_all.clicked.connect(lambda: (
                QApplication.clipboard().setText(guide_box.toPlainText()),
                show_toast(self.window(), "Đã copy link + hướng dẫn ký số!")
            ))
            btn_row.addWidget(btn_copy_all, 1)

            btn_open = QPushButton("🌐  Mở kiểm tra")
            btn_open.setStyleSheet(
                "background:#0ea5e9;color:white;font-weight:bold;"
                "border-radius:8px;padding:10px 0;font-size:13px;")
            btn_open.setCursor(Qt.CursorShape.PointingHandCursor)
            btn_open.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
            btn_open.clicked.connect(
                lambda: self._open_external_url_with_confirm(drive_link, "Mở link Google Drive")
            )
            btn_row.addWidget(btn_open, 1)

            layout.addLayout(btn_row)

            btn_close = QPushButton("Đóng")
            btn_close.clicked.connect(dlg.accept)
            btn_close.setStyleSheet("background:transparent;color:#6b7280;border:none;padding:4px;")
            layout.addWidget(btn_close, alignment=Qt.AlignmentFlag.AlignRight)

            dlg.exec()



        thread.done.connect(on_done)
        thread.start()






    def _on_import_click_legacy(self, contract_item: dict, category: str):
        """Chọn file PDF thủ công để cập nhật bản ký."""
        from PySide6.QtWidgets import QFileDialog
        import shutil
        import os
        from core.drive_sync import _ROOT
        
        c_id = contract_item["id"]
        file_path, _ = QFileDialog.getOpenFileName(self.window(), "Chọn bản ký hợp đồng (PDF)", "", "PDF Files (*.pdf)")
        
        if file_path:
            # Di chuyển vào thư mục completed
            cat_dir = self._category_dir_name(category)
            dest_dir = get_company_root_path(os.path.join("contracts", cat_dir, "data", contract_item["date"], "completed"))
            os.makedirs(dest_dir, exist_ok=True)
            
            dest_file = os.path.join(dest_dir, f"{c_id}_signed.pdf")
            shutil.copy2(file_path, dest_file)
            
            show_toast(self.window(), f"✅ Đã import bản ký cho {c_id}.")

    def _handle_sync(self, category, silent=False):
        """Hàm xử lý đồng bộ toàn bộ mục, chạy bằng QThread."""
        if not self._plan_has_feature("has_backup_sync"):
            if not silent:
                self._ensure_plan_feature("has_backup_sync", "Đồng bộ Google Drive", "STARTER, PRO hoặc OFFICE")
            return

        if not silent:
            show_toast(self.window(), f"⏳ Đang đồng bộ {category} lên Drive (vui lòng đợi)...")
        
        if not hasattr(self, '_sync_workers'):
            self._sync_workers = []   # list giữ strong reference, tránh GC

        # Dọn sạch worker đã done trước khi thêm mới
        self._sync_workers = [w for w in self._sync_workers if w.isRunning()]

        worker = SyncWorker(category)
        self._sync_workers.append(worker)   # giữ reference để tránh QThread destroyed

        def _on_done(res):
            self._on_sync_finished(res, category, silent)
            # Sau khi xong, dọn worker này khỏi list
            try:
                self._sync_workers.remove(worker)
            except ValueError:
                pass

        worker.finished_sig.connect(_on_done)
        worker.start()
        

    def _on_sync_finished(self, res, category, silent):
        if res.get("success"):
            if not silent:
                show_toast(self.window(), f"✅ {res['message']}")
            self._reload_table(category)
        else:
            if not silent:
                show_toast(self.window(), f"❌ {res['message']}", type="error")

    def _on_add_contract_click(self, category: str):
        """Nhập hợp đồng trực tiếp từ file PDF: tự tạo mã, quét MST để phân loại Bên A/B tự động."""
        from PySide6.QtWidgets import QFileDialog, QMessageBox
        from datetime import datetime
        import random, string, os, sys, shutil, json
        from core.drive_sync import _ROOT
        from core.contracts_db import scan_contract_files
        
        # 1. Mở dialog chọn 1 hoặc nhiều file PDF / DOCX
        paths, _ = QFileDialog.getOpenFileNames(
            self.window(), "Chọn file Hợp Đồng", "",
            "Hợp đồng (*.pdf *.docx);;PDF (*.pdf);;Word (*.docx)"
        )
        if not paths:
            return

        # 2. Đọc company_mst từ settings.json để làm mốc phân loại (Bên B cho sale, Bên A cho purchase)
        host_mst = ""
        try:
            from core.utils import get_app_data_path
            settings_path = get_app_data_path("settings.json")
            if os.path.exists(settings_path):
                with open(settings_path, "r", encoding="utf-8") as f:
                    cfg = json.load(f)
                    host_mst = str(cfg.get("company", {}).get("tax_id", "")).strip().lower()
        except: pass

        success_count = 0
        completed_contract_names: list[str] = []
        try:
            import re as _re
            from core.pdf_signature_verifier import verify_pdf_signature

            # --- Regex bóc suffix (dùng chung cho cả vòng lặp) ---
            _SA_PAT = r'(?:a(?:ky|sign|_?signed?)|ky_?a|sign_?a|ben_?a)'
            _SB_PAT = r'(?:b(?:ky|sign|_?signed?)|ky_?b|sign_?b|ben_?b)'
            _BOTH_PAT = rf'(?:{_SA_PAT}[_ ]{_SB_PAT}|{_SB_PAT}[_ ]{_SA_PAT})'
            _SUFFIX_RE = _re.compile(
                rf'(?i)[_ ]({_BOTH_PAT}|{_SA_PAT}|{_SB_PAT}|d?a?ky|signed?|sign)([_ ].*)?$'
            )
            warn_files: list = []

            for path in paths:
                ext = os.path.splitext(path)[1].lower()

                # ── File DOCX → Draft ngay, không cần verify chữ ký ──────────
                if ext == ".docx":
                    base_name = os.path.splitext(os.path.basename(path))[0]
                    _sfx_m   = _SUFFIX_RE.search(base_name)
                    c_id     = _SUFFIX_RE.sub('', base_name).strip().strip('_') if _sfx_m else base_name
                    if not c_id:
                        date_part  = datetime.now().strftime("%Y%m%d")
                        suffix_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=4))
                        c_id       = f"HD-{date_part}-{suffix_str}"
                    seq_suffix = ""
                    new_signers = set()
                    c_date  = datetime.now().strftime("%Y-%m-%d")
                    cat_dir = self._category_dir_name(category)
                    dest_dir = get_company_root_path(os.path.join("contracts", cat_dir, "data", c_date, "draft"))
                    os.makedirs(dest_dir, exist_ok=True)
                    target_path = os.path.join(dest_dir, f"{c_id}.docx")
                    shutil.copy2(path, target_path)
                    success_count += 1
                    continue   # bỏ qua toàn bộ luồng verify bên dưới

                # ── BƯỚC 1: Đọc suffix từ tên file (PDF) ────────────────────
                base_name = os.path.splitext(os.path.basename(path))[0]
                _sfx_m = _SUFFIX_RE.search(base_name)
                _sfx_str = _sfx_m.group(0).lower() if _sfx_m else ""
                fn_has_a = bool(_re.search(_SA_PAT, _sfx_str, _re.IGNORECASE)) if _sfx_str else False
                fn_has_b = bool(_re.search(_SB_PAT, _sfx_str, _re.IGNORECASE)) if _sfx_str else False

                # Bóc c_id (bỏ suffix)
                c_id = _SUFFIX_RE.sub('', base_name).strip().strip('_') if _sfx_m else base_name

                # ── BƯỚC 2: Xác minh chữ ký số ──────────────────────────────
                verify_res = verify_pdf_signature(path)
                sig_has_host = False
                sig_has_other = False
                extracted_msts = []

                if verify_res.get("is_signed") and verify_res.get("signatures"):
                    for sig in verify_res["signatures"]:
                        mst = sig.get("mst", "").strip().lower()
                        if mst:
                            extracted_msts.append(mst)
                            if host_mst and mst.replace("-","") == host_mst.replace("-",""):
                                sig_has_host = True
                            else:
                                sig_has_other = True

                # Nếu ký nhưng không đọc được MST → coi là bên kia ký
                if verify_res.get("is_signed") and not extracted_msts:
                    sig_has_other = True

                # ── BƯỚC 3: Phân loại ─────────────────────────────────────────
                # Ưu tiên tuyệt đối suffix tên file.
                # Chữ ký số chỉ dùng khi tên file KHÔNG có suffix nào.
                if fn_has_a or fn_has_b:
                    # Tên file đã có suffix → tin hoàn toàn vào tên file
                    signed_a = fn_has_a
                    signed_b = fn_has_b
                else:
                    # Không có suffix → dùng chữ ký số để phân loại
                    if category == "sale":
                        signed_b = sig_has_host   # host = B
                        signed_a = sig_has_other  # khách = A
                    else:
                        signed_a = sig_has_host   # host = A
                        signed_b = sig_has_other  # nhà CC = B

                if signed_a and signed_b:
                    if fn_has_a and fn_has_b:
                        idx_a = _sfx_str.find("aky")
                        idx_b = _sfx_str.find("bky")
                        if idx_a != -1 and idx_b != -1 and idx_b < idx_a:
                            seq_suffix = "_bky_aky"
                        else:
                            seq_suffix = "_aky_bky"
                    else:
                        seq_suffix = "_aky_bky"
                elif signed_b:
                    seq_suffix = "_bky"
                elif signed_a:
                    seq_suffix = "_aky"
                else:
                    seq_suffix = ""  # chưa ai ký → draft, giữ nguyên tên

                new_signers = set()
                if signed_a: new_signers.add("a")
                if signed_b: new_signers.add("b")


                if not c_id:
                    date_part = datetime.now().strftime("%Y%m%d")
                    suffix_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=4))
                    c_id = f"HD-{date_part}-{suffix_str}"


                # Kiểm tra trùng lặp và Xử lý ưu tiên chữ ký
                from core.contracts_db import get_connection
                table_name = self._category_table_name(category)
                
                with get_connection() as conn:
                    existing = conn.execute(f"SELECT path, sign_sequence FROM {table_name} WHERE name_file = ?", (c_id,)).fetchone()
                    if existing:
                        old_seq = existing["sign_sequence"] if existing["sign_sequence"] else ""
                        old_signers = {s.strip() for s in old_seq.split(",")} if old_seq else set()
                        old_signers = {s for s in old_signers if s in ("a", "b")}
                        
                        replace_old = False
                        if len(new_signers) > len(old_signers):
                            replace_old = True
                        elif len(new_signers) < len(old_signers):
                            replace_old = False
                        else:
                            # Số lượng chữ ký bằng nhau
                            if len(new_signers) == 1:
                                prioritized_side = "a" if category == "sale" else "b"
                                if prioritized_side in new_signers and prioritized_side not in old_signers:
                                    replace_old = True
                                elif prioritized_side in old_signers and prioritized_side not in new_signers:
                                    replace_old = False
                                else:
                                    replace_old = True
                            else:
                                replace_old = True
                                
                        if not replace_old:
                            print(f"[Import] Bỏ qua file {path} do file cũ {existing['path']} ưu tiên hơn.")
                            continue
                            
                        # Chuyển file cũ vào thùng rác
                        old_path = existing["path"]
                        if old_path and os.path.exists(old_path):
                            try:
                                from core.utils import move_to_trash
                                move_to_trash(old_path)
                            except: pass
                                
                # Copy file
                c_date = datetime.now().strftime("%Y-%m-%d")
                cat_dir = self._category_dir_name(category)
                target_folder = "completed" if ("_aky" in seq_suffix and "_bky" in seq_suffix) else "pending"
                
                dest_dir = get_company_root_path(os.path.join("contracts", cat_dir, "data", c_date, target_folder))
                os.makedirs(dest_dir, exist_ok=True)
                
                target_path = os.path.join(dest_dir, f"{c_id}{seq_suffix}.pdf")
                shutil.copy2(path, target_path)
                if signed_a and signed_b:
                    completed_contract_names.append(c_id)

                # ── Kiểm tra chữ ký số sau khi copy (nếu phân loại theo suffix tên file) ──
                if fn_has_a or fn_has_b:
                    try:
                        _vr = verify_pdf_signature(path)
                        if not _vr.get("is_signed") or not _vr.get("is_valid", True):
                            warn_files.append(os.path.basename(path))
                    except Exception:
                        warn_files.append(os.path.basename(path))

                success_count += 1

            if success_count > 0:
                # Xóa cache chữ ký của file vừa import để buộc re-verify (tránh cache cũ 1 chữ ký)
                try:
                    from core.contracts_db import get_connection as _gc
                    with _gc() as _conn:
                        _conn.execute("DELETE FROM signature_cache WHERE path LIKE ?",
                                      (f"%{c_id}%",))
                        _conn.commit()
                except Exception:
                    pass
                scan_contract_files(category, full_refresh=True)
                self._reload_table(category)
                from gui.ui_helpers import show_toast
                show_toast(self.window(), f"✅ Đã nhập & tự động phân tích {success_count} hợp đồng!")
                if warn_files:
                    names = "\n".join(warn_files)
                    show_toast(
                        self.window(),
                        f"⚠️ Không xác minh được chữ ký số:\n{names}",
                        type="warning"
                    )
                self._prompt_revenue_entry_for_completed_imports(category, completed_contract_names)


        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi Import", str(e))

    def _on_download_file_clicked(self, category: str):
        """Mở thư mục lưu file PDF/DOCX của hợp đồng đang chọn trong bảng."""
        import os
        from PySide6.QtWidgets import QMessageBox

        if category == "sale":
            table = self.table_sale
        elif category == "purchase":
            table = self.table_purchase
        else:
            table = self.table_other
        curr = table.currentRow()
        if curr < 0:
            QMessageBox.warning(
                self.window(), "Chưa chọn hợp đồng",
                "Bạn vui lòng bấm chọn một hợp đồng trong danh sách trước khi mở thư mục lưu file."
            )
            return

        item = table.item(curr, 2)   # col 2 = Số HĐ
        if not item:
            return
        c_name    = item.text().strip()
        date_item = table.item(curr, 1)
        c_date    = date_item.text().strip() if date_item else ""
        cat_dir   = self._category_dir_name(category)
        candidates: list = []

        try:
            if c_date:
                date_dir = get_company_root_path(
                    os.path.join("contracts", cat_dir, "data", c_date))
                for sub in ("completed", "pending", "draft"):
                    sub_path = os.path.join(date_dir, sub)
                    if os.path.isdir(sub_path):
                        for fname in os.listdir(sub_path):
                            if fname.startswith(c_name) and fname.lower().endswith((".pdf", ".docx")):
                                pri = 0 if sub == "completed" else 1 if sub == "pending" else 2
                                candidates.append((pri, os.path.join(sub_path, fname)))
            if not candidates:
                base_data = get_company_root_path(
                    os.path.join("contracts", cat_dir, "data"))
                if os.path.isdir(base_data):
                    for date_sub in os.listdir(base_data):
                        for sub in ("completed", "pending", "draft"):
                            sub_path = os.path.join(base_data, date_sub, sub)
                            if os.path.isdir(sub_path):
                                for fname in os.listdir(sub_path):
                                    if fname.startswith(c_name) and fname.lower().endswith((".pdf", ".docx")):
                                        pri = 0 if sub == "completed" else 1 if sub == "pending" else 2
                                        candidates.append((pri, os.path.join(sub_path, fname)))
        except Exception:
            pass

        if not candidates:
            QMessageBox.warning(
                self.window(), "Không tìm thấy file",
                f"Không tìm thấy file hợp đồng [{c_name}] trên đĩa cứng.\n"
                "Dùng nút Làm mới để quét lại hoặc kiểm tra thư mục contracts/."
            )
            return

        # Ưu tiên: completed > pending > draft; cùng folder thì file suffix dài hơn trước
        candidates.sort(key=lambda x: (x[0], -len(os.path.basename(x[1]))))
        best_path = candidates[0][1]
        folder_to_open = os.path.dirname(best_path)
        try:
            os.startfile(folder_to_open)
            show_toast(self.window(), f"✅ Đã mở thư mục lưu file: {folder_to_open}")
        except Exception as e:
            QMessageBox.critical(self.window(), "Lỗi mở thư mục",
                f"Không thể mở thư mục:\n{folder_to_open}\n\nFile liên quan:\n{best_path}\n\nLỗi: {e}")
