import os
import json
import traceback
from datetime import datetime
import subprocess

import pandas as pd
from docx import Document
from docx.shared import Pt
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.enum.table import WD_ALIGN_VERTICAL
from docx.oxml.ns import qn, nsdecls
from docx.oxml import OxmlElement, parse_xml

import tkinter as tk
from tkinter import ttk, filedialog, messagebox, simpledialog

try:
    import win32com.client

    WIN32COM_AVAILABLE = True
except ImportError:
    WIN32COM_AVAILABLE = False

WORKDIR = os.getcwd()
TEMPLATES_DIR = os.path.join(WORKDIR, "templates")
CONFIG_DIR = os.path.join(WORKDIR, "config")
PROFILES_DIR = os.path.join(CONFIG_DIR, "profiles")

for d in (TEMPLATES_DIR, CONFIG_DIR, PROFILES_DIR):
    os.makedirs(d, exist_ok=True)

SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.json")
PLACEHOLDERS_FILE = os.path.join(CONFIG_DIR, "placeholders.json")
MANDATORY_COLS = ['Объект', 'Свойство', 'Значение']


class Logger:
    def __init__(self):
        self.history = []

    def log(self, msg):
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        record = f"[{ts}] {msg}"
        self.history.append(record)
        print(record)
        if hasattr(app_ref, "txt_log") and app_ref.txt_log:
            app_ref.txt_log.config(state='normal')
            app_ref.txt_log.insert("end", record + "\n")
            app_ref.txt_log.see("end")
            app_ref.txt_log.config(state='disabled')

    def get_text(self):
        return "\n".join(self.history)

    def save_to_file(self, path):
        try:
            with open(path, 'w', encoding='utf-8') as f:
                f.write(self.get_text())
            self.log(f"Лог сохранён: {path}")
        except Exception as e:
            self.log(f"Ошибка сохранения лога: {e}")


logger = Logger()
app_ref = None  # Ссылка на приложение, для логирования в UI


class Settings:
    def __init__(self):
        self.data = {
            "table_style": {
                "header_bold": True,
                "alignment": "center",
                "font_name": "Calibri",
                "font_size": 11,
                "border_style": "тонкая",
                "section_header_font_name": "Calibri",
                "section_header_font_size": 12,
                "section_header_bold": True,
                "section_header_align": "center",
                "section_header_bg_color": "FFFF00"
            },
            "export": {
                "format": "docx",
                "pdf_password": "",
                "compress_images": True,
                "template": None
            }
        }
        self.load()

    def load(self):
        if os.path.exists(SETTINGS_FILE):
            try:
                with open(SETTINGS_FILE, 'r', encoding='utf-8') as f:
                    d = json.load(f)
                self.data.update(d)
                exp = self.data.get('export', {})
                if 'format' not in exp:
                    exp['format'] = 'docx'
                if 'pdf_password' not in exp:
                    exp['pdf_password'] = ''
                if 'compress_images' not in exp:
                    exp['compress_images'] = True
                self.data['export'] = exp

                ts = self.data.get('table_style', {})
                defaults = {
                    "border_style": "тонкая",
                    "section_header_font_name": "Calibri",
                    "section_header_font_size": 12,
                    "section_header_bold": True,
                    "section_header_align": "center",
                    "section_header_bg_color": "FFFF00"
                }
                for k, v in defaults.items():
                    if k not in ts:
                        ts[k] = v
                self.data['table_style'] = ts

                logger.log("Настройки загружены")
            except Exception as e:
                logger.log(f"Ошибка загрузки настроек: {e}")

    def save(self):
        try:
            with open(SETTINGS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            logger.log("Настройки сохранены")
        except Exception as e:
            logger.log(f"Ошибка сохранения настроек: {e}")

    def save_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'w', encoding='utf-8') as f:
                json.dump(self.data, f, ensure_ascii=False, indent=4)
            logger.log(f"Профиль сохранён: {filename}")
            return True
        except Exception as e:
            logger.log(f"Ошибка сохранения профиля: {e}")
            return False

    def load_profile(self, filename):
        try:
            with open(os.path.join(PROFILES_DIR, filename), 'r', encoding='utf-8') as f:
                d = json.load(f)
            self.data.update(d)

            ts = self.data.get('table_style', {})
            defaults = {
                "border_style": "тонкая",
                "section_header_font_name": "Calibri",
                "section_header_font_size": 12,
                "section_header_bold": True,
                "section_header_align": "center",
                "section_header_bg_color": "FFFF00"
            }
            for k, v in defaults.items():
                if k not in ts:
                    ts[k] = v
            self.data['table_style'] = ts

            logger.log(f"Профиль загружен: {filename}")
            return True
        except Exception as e:
            logger.log(f"Ошибка загрузки профиля: {e}")
            return False


settings = Settings()


class Placeholders:
    def __init__(self):
        self.placeholders = {}
        self.load()

    def load(self):
        if os.path.isfile(PLACEHOLDERS_FILE):
            try:
                with open(PLACEHOLDERS_FILE, 'r', encoding='utf-8') as f:
                    self.placeholders = json.load(f)
                logger.log("Плейсхолдеры загружены")
            except Exception as e:
                logger.log(f"Ошибка загрузки плейсхолдеров: {e}")

    def save(self):
        try:
            with open(PLACEHOLDERS_FILE, 'w', encoding='utf-8') as f:
                json.dump(self.placeholders, f, ensure_ascii=False, indent=4)
            logger.log("Плейсхолдеры сохранены")
        except Exception as e:
            logger.log(f"Ошибка сохранения плейсхолдеров: {e}")


placeholders = Placeholders()


def extract_article_full(obj):
    if not isinstance(obj, str) or not obj.strip():
        return ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        return obj[idx + 4:].strip()
    words = obj.strip().split()
    if len(words) <= 2:
        return obj.strip()
    return " ".join(words[-3:]).strip()


def extract_product_type(obj):
    if not isinstance(obj, str) or not obj.strip():
        return ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        part = obj[:idx].strip()
        if part:
            return part
        return "Неизвестно"
    words = obj.strip().split()
    if len(words) <= 3:
        return " ".join(words)
    return " ".join(words[:3])


def extract_param(prop):
    if not isinstance(prop, str):
        return ""
    return prop.split("_")[-1].strip()


def load_and_transform_excel_grouped(path):
    logger.log(f"Загрузка файла: {path}")
    df = pd.read_excel(path, engine="openpyxl")
    missing = [c for c in MANDATORY_COLS if c not in df.columns]
    if missing:
        raise ValueError(f"Отсутствуют колонки: {missing}")

    df["Тип товара"] = df["Объект"].apply(extract_product_type)
    df["Артикул"] = df["Объект"].apply(extract_article_full)
    df["Параметр"] = df["Свойство"].apply(extract_param)
    df["Значение"] = df["Значение"].astype(str)
    df = df[(df["Артикул"] != "") & (df["Параметр"] != "")].reset_index(drop=True)

    grouped = {}
    for art, gdf in df.groupby("Артикул"):
        obj_full = gdf["Объект"].iloc[0]
        prod_type = gdf["Тип товара"].iloc[0]

        sections = []
        current_header = None
        current_params = []

        for _, row in gdf.iterrows():
            if row["Значение"].strip().lower() == "<раздел>":
                if current_header is not None or current_params:
                    sections.append({"header": current_header, "params": current_params})
                current_header = row["Параметр"]
                current_params = []
            else:
                current_params.append({"parameter": row["Параметр"], "value": row["Значение"]})

        if current_header is not None or current_params:
            sections.append({"header": current_header, "params": current_params})

        if not sections:
            sections = [
                {
                    "header": None,
                    "params": [{"parameter": r["Параметр"], "value": r["Значение"]} for _, r in gdf.iterrows()],
                }
            ]

        grouped[art] = {"object_full": obj_full, "product_type": prod_type, "sections": sections}
    logger.log(f"Данные сгруппированы по артикулам: {len(grouped)}")
    return grouped


def inline_replace_in_paragraph(paragraph, placeholder, replacement):
    runs = paragraph.runs
    full_text = "".join(run.text for run in runs)
    if placeholder not in full_text:
        return
    new_text = full_text.replace(placeholder, replacement)
    for run in runs:
        run.text = ""
    if runs:
        runs[0].text = new_text


def docx_replace_placeholder(doc, key, value):
    ph = "{{" + key + "}}"
    for p in doc.paragraphs:
        if ph in p.text:
            inline_replace_in_paragraph(p, ph, str(value))
    for tbl in doc.tables:
        for row in tbl.rows:
            for cell in row.cells:
                for p in cell.paragraphs:
                    if ph in p.text:
                        inline_replace_in_paragraph(p, ph, str(value))
    replace_placeholder_in_header_footer(doc, key, value)


def replace_placeholder_in_header_footer(doc, key, value):
    ph = "{{" + key + "}}"
    for section in doc.sections:
        header = section.header
        for p in header.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in header.tables:
            for row in tbl.rows:
                for cell in row.cells:
                    for p in cell.paragraphs:
                        if ph in p.text:
                            inline_replace_in_paragraph(p, ph, str(value))
        footer = section.footer
        for p in footer.paragraphs:
            if ph in p.text:
                inline_replace_in_paragraph(p, ph, str(value))
        for tbl in footer.tables:
            for row in tbl.rows:
                for cell in row.cells:
                    for p in cell.paragraphs:
                        if ph in p.text:
                            inline_replace_in_paragraph(p, ph, str(value))


def set_table_border(table, border_style):
    if border_style == 'нет':
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="nil"/>
            <w:left w:val="nil"/>
            <w:bottom w:val="nil"/>
            <w:right w:val="nil"/>
            <w:insideH w:val="nil"/>
            <w:insideV w:val="nil"/>
        </w:tblBorders>
        """ % nsdecls('w')
    elif border_style == 'тонкая':
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>
        </w:tblBorders>
        """ % nsdecls('w')
    elif border_style == 'толстая':
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:left w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:bottom w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:right w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:insideH w:val="single" w:sz="12" w:space="0" w:color="000000"/>
            <w:insideV w:val="single" w:sz="12" w:space="0" w:color="000000"/>
        </w:tblBorders>
        """ % nsdecls('w')
    else:
        border_xml = """
        <w:tblBorders %s>
            <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>
            <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>
        </w:tblBorders>
        """ % nsdecls('w')

    tblPr = table._element.tblPr
    for child in tblPr.findall("{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tblBorders"):
        tblPr.remove(child)
    borders = parse_xml(border_xml)
    tblPr.append(borders)


def set_cell_background(cell, hex_color):
    tc = cell._tc
    tcPr = tc.get_or_add_tcPr()
    shd = OxmlElement('w:shd')
    shd.set(qn('w:fill'), hex_color)
    tcPr.append(shd)


class App(tk.Tk):
    def __init__(self):
        super().__init__()
        global app_ref
        app_ref = self

        self.title("Автоматизация техдокументации")
        self.geometry("1200x720")
        self.minsize(1000, 600)

        self.grid_rowconfigure(0, weight=1)
        self.grid_columnconfigure(0, weight=1)

        self.grouped_data = {}
        self.filtered_df = pd.DataFrame()
        self.filtered_df_backup = pd.DataFrame()
        self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])

        # Храним параметры и их возможные значения для выбора фильтров (множество значений для каждого параметра)
        self.all_params = []  # Список всех параметров
        self.param_values = {}  # { param_name: set(values) }

        self.user_placeholders = placeholders.placeholders.copy()
        self.templates = []
        self.current_template = None
        self.selected_profile = None

        self.filter_params_selected = {}  # {param_name: value} для нескольких параметров

        self.create_widgets()
        self.create_menu()       # <-- Добавлен
        self.bind_shortcuts()    # <-- Добавлен
        self.load_templates()
        self.load_profiles()
        self.load_placeholders_to_tree()
        self.load_settings_to_gui()

        # Новая автоматическая генерация плейсхолдеров из параметров товаров
        self.create_placeholders_from_loaded_products()

        logger.log("Приложение запущено")

    # -------------------- Добавленные методы -------------------

    def create_placeholders_from_loaded_products(self):
        """
        Автоматически создает плейсхолдеры из всех уникальных параметров из загруженных товаров.
        Название плейсхолдера - это название параметра,
        Значение - это {{Название параметра}}.
        Эти плейсхолдеры добавляются в user_placeholders и сохраняются.
        """
        if not self.grouped_data:
            logger.log("Данные товаров отсутствуют - автоматическое создание плейсхолдеров пропущено")
            return

        # Собираем все уникальные параметры
        unique_params = set()
        for info in self.grouped_data.values():
            for section in info['sections']:
                for param in section['params']:
                    unique_params.add(param['parameter'])

        # Создаем плейсхолдеры из параметров
        updated = False
        for param in unique_params:
            if param not in self.user_placeholders:
                self.user_placeholders[param] = "{{" + param + "}}"
                updated = True

        if updated:
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            logger.log(f"Автоматически создано плейсхолдеров из параметров: {len(unique_params)}")
        else:
            logger.log("Автоматическое создание плейсхолдеров: никаких новых плейсхолдеров не добавлено")

    def create_menu(self):
        menubar = tk.Menu(self)

        # Файл
        file_menu = tk.Menu(menubar, tearoff=0)
        file_menu.add_command(label="Загрузить Excel", command=self.load_excel_dialog)
        file_menu.add_separator()
        file_menu.add_command(label="Сохранить лог...", command=self.export_log)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", command=self.quit)
        menubar.add_cascade(label="Файл", menu=file_menu)

        # Корзина
        basket_menu = tk.Menu(menubar, tearoff=0)
        basket_menu.add_command(label="Удалить выделенные", command=self.remove_selected_from_basket)
        basket_menu.add_command(label="Очистить корзину", command=self.clear_basket)
        menubar.add_cascade(label="Корзина", menu=basket_menu)

        # Помощь
        help_menu = tk.Menu(menubar, tearoff=0)
        help_menu.add_command(label="О программе", command=self.show_about)
        menubar.add_cascade(label="Помощь", menu=help_menu)

        self.config(menu=menubar)

    def bind_shortcuts(self):
        # Пример добавления горячих клавиш, в данный момент пусто
        # self.bind('<Control-o>', lambda e: self.load_excel_dialog())
        # self.bind('<Control-s>', lambda e: self.save_profile())
        pass

    def show_about(self):
        messagebox.showinfo("О программе", "Автоматизация техдокументации\nВерсия 1.0")

    # ------------------------------------------------------------

    # Остальной код класса App без изменений:

    def create_widgets(self):
        self.nb = ttk.Notebook(self)
        self.nb.grid(row=0, column=0, sticky="nsew")

        # --- Вкладка данных ---
        tab_data = ttk.Frame(self.nb)
        self.nb.add(tab_data, text="Данные")

        tab_data.grid_rowconfigure(1, weight=1)
        tab_data.grid_columnconfigure(0, weight=1)
        tab_data.grid_columnconfigure(1, weight=0)

        # Фрейм фильтров сверху
        filter_frame = ttk.Frame(tab_data)
        filter_frame.grid(row=0, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        filter_frame.grid_columnconfigure(7, weight=1)

        # Быстрый фильтр по артикулу
        ttk.Label(filter_frame, text="Поиск по артикулу:").grid(row=0, column=0, sticky="w")
        self.var_quick_search = tk.StringVar()
        ent_quick_search = ttk.Entry(filter_frame, textvariable=self.var_quick_search)
        ent_quick_search.grid(row=0, column=1, sticky="ew", padx=3)
        filter_frame.grid_columnconfigure(1, weight=2)
        ent_quick_search.bind("<KeyRelease>", self.quick_search)

        # Фильтры с несколькими параметрами и значениями
        ttk.Label(filter_frame, text="Фильтры параметров:").grid(row=0, column=2, sticky="w", padx=10)

        # Кнопка добавить новый фильтр
        self.btn_add_param_filter = ttk.Button(filter_frame, text="+ Фильтр", command=self.add_param_filter_row)
        self.btn_add_param_filter.grid(row=0, column=7, sticky="e")

        # Frame, куда будем динамически добавлять фильтры
        self.param_filters_container = ttk.Frame(filter_frame)
        self.param_filters_container.grid(row=1, column=0, columnspan=8, sticky="ew", pady=5)
        self.param_filters_container.grid_columnconfigure(0, weight=1)

        self.param_filter_rows = []  # список словарей: each {param_combo, value_combo, del_btn}

        # Ещё ниже: таблица и правая панель параметров
        tbl_frame = ttk.Frame(tab_data)
        tbl_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        tbl_frame.grid_rowconfigure(0, weight=1)
        tbl_frame.grid_columnconfigure(0, weight=1)

        self.tree_data = ttk.Treeview(tbl_frame, show="headings", selectmode="extended")
        self.tree_data.grid(row=0, column=0, sticky="nsew")
        self.tree_data.bind("<Double-1>", self.add_selected_to_basket)
        self.tree_data.bind("<Button-1>", self.show_item_params)
        self.tree_data.bind("<Button-3>", self.context_menu_data)

        scr_y = ttk.Scrollbar(tbl_frame, orient="vertical", command=self.tree_data.yview)
        scr_y.grid(row=0, column=1, sticky="ns")
        scr_x = ttk.Scrollbar(tbl_frame, orient="horizontal", command=self.tree_data.xview)
        scr_x.grid(row=1, column=0, sticky="ew")
        self.tree_data.configure(yscrollcommand=scr_y.set, xscrollcommand=scr_x.set)

        # Правая панель параметров выбранного товара
        right_frame = ttk.Frame(tab_data, relief="groove", borderwidth=2)
        right_frame.grid(row=1, column=1, sticky="nsew", padx=5, pady=5)
        tab_data.grid_columnconfigure(1, weight=0)
        right_frame.grid_rowconfigure(1, weight=1)
        right_frame.grid_columnconfigure(0, weight=1)

        ttk.Label(right_frame, text="Параметры выбранного товара:").grid(row=0, column=0, sticky="w", padx=5, pady=(0, 5))
        self.tree_params = ttk.Treeview(right_frame, show="headings", columns=["Параметр", "Значение"], height=20)
        self.tree_params.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        self.tree_params.heading("Параметр", text="Параметр")
        self.tree_params.heading("Значение", text="Значение")
        self.tree_params.column("Параметр", width=150, anchor="w")
        self.tree_params.column("Значение", width=150, anchor="w")

        sb_params_y = ttk.Scrollbar(right_frame, orient="vertical", command=self.tree_params.yview)
        sb_params_y.grid(row=1, column=1, sticky="ns")
        self.tree_params.configure(yscrollcommand=sb_params_y.set)

        # Нижняя часть - корзина
        basket_frame = ttk.LabelFrame(self, text="Корзина товаров")
        basket_frame.grid(row=1, column=0, sticky="ew", padx=5, pady=5)
        self.grid_rowconfigure(1, weight=0)

        basket_frame.grid_rowconfigure(0, weight=1)
        basket_frame.grid_columnconfigure(0, weight=1)

        self.tree_basket = ttk.Treeview(basket_frame, show="headings", selectmode="extended", height=6)
        self.tree_basket.grid(row=0, column=0, sticky="nsew", padx=5, pady=5)
        self.tree_basket.bind("<Delete>", lambda e: self.remove_selected_from_basket())
        self.tree_basket.bind("<Button-3>", self.context_menu_basket)

        sb_basket_y = ttk.Scrollbar(basket_frame, orient="vertical", command=self.tree_basket.yview)
        sb_basket_y.grid(row=0, column=1, sticky="ns", pady=5)
        sb_basket_x = ttk.Scrollbar(basket_frame, orient="horizontal", command=self.tree_basket.xview)
        sb_basket_x.grid(row=1, column=0, sticky="ew", padx=5)
        self.tree_basket.configure(yscrollcommand=sb_basket_y.set, xscrollcommand=sb_basket_x.set)

        ctrl_basket = ttk.Frame(basket_frame)
        ctrl_basket.grid(row=2, column=0, columnspan=2, sticky="ew", padx=5, pady=5)
        ctrl_basket.grid_columnconfigure(3, weight=1)
        ttk.Button(ctrl_basket, text="Удалить выделенные", command=self.remove_selected_from_basket).grid(row=0, column=0, padx=5)
        ttk.Button(ctrl_basket, text="Очистить корзину", command=self.clear_basket).grid(row=0, column=1, padx=5)
        ttk.Button(ctrl_basket, text="Генерировать документ", command=self.generate_document).grid(row=0, column=2, padx=5)

        # --- Вкладка настроек и генерации---
        tab_set_gen = ttk.Frame(self.nb)
        self.nb.add(tab_set_gen, text="Настройки и Генерация")

        tab_set_gen.grid_rowconfigure(3, weight=1)
        tab_set_gen.grid_columnconfigure(0, weight=1)

        prof_frame = ttk.LabelFrame(tab_set_gen, text="Профили настроек")
        prof_frame.grid(row=0, column=0, sticky="ew", padx=10, pady=5)
        prof_frame.grid_columnconfigure(1, weight=1)

        self.combo_profiles = ttk.Combobox(prof_frame, state='readonly')
        self.combo_profiles.grid(row=0, column=0, sticky="ew", padx=5, pady=5)
        self.combo_profiles.bind("<<ComboboxSelected>>", self.on_profile_change)

        btn_load_profile = ttk.Button(prof_frame, text="Загрузить профиль", command=self.load_profile)
        btn_load_profile.grid(row=0, column=1, padx=5)
        btn_save_profile = ttk.Button(prof_frame, text="Сохранить профиль", command=self.save_profile)
        btn_save_profile.grid(row=0, column=2, padx=5)
        btn_refresh_profiles = ttk.Button(prof_frame, text="Обновить список", command=self.load_profiles)
        btn_refresh_profiles.grid(row=0, column=3, padx=5)

        ttk.Label(tab_set_gen, text="Выбор шаблона:").grid(row=1, column=0, sticky='w', padx=10, pady=(0, 5))
        self.combo_templates = ttk.Combobox(tab_set_gen, state='readonly')
        self.combo_templates.grid(row=2, column=0, sticky="ew", padx=10)
        self.combo_templates.bind("<<ComboboxSelected>>", self.on_template_change)
        btn_preview_template = ttk.Button(tab_set_gen, text="Предпросмотр шаблона", command=self.preview_template)
        btn_preview_template.grid(row=3, column=0, sticky='w', padx=10, pady=5)

        ttk.Label(tab_set_gen, text="Плейсхолдеры:").grid(row=4, column=0, sticky='w', padx=10, pady=(5, 3))
        self.tree_placeholders = ttk.Treeview(tab_set_gen, columns=("name", "value"), show='headings')
        self.tree_placeholders.grid(row=5, column=0, sticky='nsew', padx=10)
        self.tree_placeholders.heading("name", text="Имя")
        self.tree_placeholders.heading("value", text="Значение")
        self.tree_placeholders.column("name", width=200, anchor="w")
        self.tree_placeholders.column("value", width=400, anchor="w")

        tab_set_gen.grid_rowconfigure(5, weight=1)

        ph_btn_frame = ttk.Frame(tab_set_gen)
        ph_btn_frame.grid(row=6, column=0, sticky="ew", padx=10, pady=5)
        for i, (text, cmd) in enumerate([
            ("Добавить", self.add_placeholder),
            ("Редактировать", self.edit_placeholder),
            ("Удалить", self.delete_placeholder),
            ("Импорт из JSON", self.import_placeholders),
            ("Экспорт в JSON", self.export_placeholders),
        ]):
            ttk.Button(ph_btn_frame, text=text, command=cmd).grid(row=0, column=i, padx=5)

        # Параметры таблицы
        style_label = ttk.Label(tab_set_gen, text="Параметры таблицы:")
        style_label.grid(row=7, column=0, sticky='w', padx=10, pady=(10, 3))
        style_frm = ttk.Frame(tab_set_gen)
        style_frm.grid(row=8, column=0, sticky="ew", padx=10, pady=5)

        style_frm.columnconfigure(10, weight=1)

        self.var_header_bold = tk.BooleanVar(value=settings.data['table_style']['header_bold'])
        ttk.Checkbutton(style_frm, text="Жирный заголовок", variable=self.var_header_bold).grid(row=0, column=0, padx=5, pady=2)

        ttk.Label(style_frm, text="Выравнивание:").grid(row=0, column=1, padx=5, pady=2, sticky='w')
        self.combo_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_align.set(settings.data['table_style']['alignment'])
        self.combo_align.grid(row=0, column=2, padx=5, pady=2)

        ttk.Label(style_frm, text="Шрифт:").grid(row=0, column=3, padx=5, pady=2, sticky='w')
        self.entry_font_name = ttk.Entry(style_frm, width=15)
        self.entry_font_name.insert(0, settings.data['table_style']['font_name'])
        self.entry_font_name.grid(row=0, column=4, padx=5, pady=2)

        ttk.Label(style_frm, text="Размер:").grid(row=0, column=5, padx=5, pady=2, sticky='w')
        self.spin_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_font_size.delete(0, 'end')
        self.spin_font_size.insert(0, settings.data['table_style']['font_size'])
        self.spin_font_size.grid(row=0, column=6, padx=5, pady=2)

        ttk.Label(style_frm, text="Стиль рамки:").grid(row=0, column=7, padx=5, pady=2, sticky='w')
        self.combo_border_style = ttk.Combobox(style_frm, values=['нет', 'тонкая', 'толстая'], state='readonly', width=10)
        self.combo_border_style.set(settings.data['table_style'].get('border_style', 'тонкая'))
        self.combo_border_style.grid(row=0, column=8, padx=5, pady=2)

        # Заголовок раздела
        ttk.Label(style_frm, text="Шрифт заголовка раздела:").grid(row=1, column=0, sticky='w', padx=5, pady=3)
        self.entry_sec_header_font_name = ttk.Entry(style_frm, width=15)
        self.entry_sec_header_font_name.grid(row=1, column=1, padx=5)
        self.entry_sec_header_font_name.insert(0, settings.data['table_style'].get("section_header_font_name", "Calibri"))

        ttk.Label(style_frm, text="Размер шрифта:").grid(row=1, column=2, sticky='w', padx=5, pady=3)
        self.spin_sec_header_font_size = ttk.Spinbox(style_frm, from_=6, to=48, width=5)
        self.spin_sec_header_font_size.grid(row=1, column=3, padx=5)
        self.spin_sec_header_font_size.delete(0, "end")
        self.spin_sec_header_font_size.insert(0, settings.data['table_style'].get("section_header_font_size", 12))

        self.var_sec_header_bold = tk.BooleanVar(value=settings.data['table_style'].get("section_header_bold", True))
        ttk.Checkbutton(style_frm, text="Жирный заголовок раздела", variable=self.var_sec_header_bold).grid(row=1, column=4, padx=5)

        ttk.Label(style_frm, text="Выравнивание заголовка:").grid(row=1, column=5, sticky='w', padx=5, pady=3)
        self.combo_sec_header_align = ttk.Combobox(style_frm, values=['left', 'center', 'right'], state='readonly', width=10)
        self.combo_sec_header_align.grid(row=1, column=6, padx=5)
        self.combo_sec_header_align.set(settings.data['table_style'].get("section_header_align", "center"))

        ttk.Label(style_frm, text="Цвет фона заголовка (HEX):").grid(row=1, column=7, sticky='w', padx=5, pady=3)
        self.entry_sec_header_bg_color = ttk.Entry(style_frm, width=12)
        self.entry_sec_header_bg_color.grid(row=1, column=8, padx=5)
        self.entry_sec_header_bg_color.insert(0, settings.data['table_style'].get("section_header_bg_color", "FFFF00"))

        # Настройки экспорта
        export_label = ttk.Label(tab_set_gen, text="Настройки экспорта:")
        export_label.grid(row=9, column=0, sticky='w', padx=10, pady=(10, 3))
        export_frm = ttk.Frame(tab_set_gen)
        export_frm.grid(row=10, column=0, sticky="ew", padx=10, pady=5)
        export_frm.grid_columnconfigure(8, weight=1)

        ttk.Label(export_frm, text="Формат файла:").grid(row=0, column=0, sticky="w", padx=5)
        self.combo_export_format = ttk.Combobox(export_frm, values=['docx', 'pdf'], state='readonly', width=10)
        self.combo_export_format.set(settings.data['export']['format'])
        self.combo_export_format.grid(row=0, column=1, sticky="w", padx=5)

        self.var_compress_images = tk.BooleanVar(value=settings.data['export']['compress_images'])
        ttk.Checkbutton(export_frm, text="Сжимать изображения", variable=self.var_compress_images).grid(row=0, column=2, sticky="w", padx=10)

        ttk.Label(export_frm, text="Пароль для PDF:").grid(row=0, column=3, sticky="w", padx=5)
        self.entry_pdf_password = ttk.Entry(export_frm, width=20, show='*')
        self.entry_pdf_password.insert(0, settings.data['export']['pdf_password'])
        self.entry_pdf_password.grid(row=0, column=4, sticky="w", padx=5)

        ttk.Button(tab_set_gen, text="Сохранить настройки в профиль", command=self.save_profile).grid(row=11, column=0, sticky="e", padx=10, pady=10)

    # ==== Методы ====

    # Новый метод: добавляет строку фильтра параметра с параметром и выбором значения, + кнопка удаления
    def add_param_filter_row(self):
        row_frame = ttk.Frame(self.param_filters_container)
        row_frame.pack(fill="x", pady=2)

        param_combo = ttk.Combobox(row_frame, state="readonly", width=25)
        param_combo['values'] = self.all_params
        param_combo.pack(side='left', padx=2)
        param_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, rf=row_frame: self.on_param_combo_change(c, rf))

        value_combo = ttk.Combobox(row_frame, state="readonly", width=25)
        value_combo.pack(side='left', padx=2)
        value_combo.bind("<<ComboboxSelected>>", lambda e, c=param_combo, v=value_combo: self.on_param_value_change(c, v))

        del_btn = ttk.Button(row_frame, text="✖", width=3,
                             command=lambda rf=row_frame: self.remove_param_filter_row(rf))
        del_btn.pack(side='left', padx=2)

        self.param_filter_rows.append({'frame': row_frame, 'param': param_combo, 'value': value_combo, 'del_btn': del_btn})

    def remove_param_filter_row(self, row_frame):
        # Удаляем из списка
        self.param_filter_rows = [r for r in self.param_filter_rows if r['frame'] != row_frame]
        row_frame.destroy()
        self.apply_all_filters()

    def on_param_combo_change(self, param_combo, row_frame):
        param = param_combo.get()
        # Найти у какой строки value_combo
        value_combo = None
        for row in self.param_filter_rows:
            if row['frame'] == row_frame:
                value_combo = row['value']
                break
        if value_combo:
            values = []
            if param and param in self.param_values:
                values = sorted(self.param_values[param])
            value_combo['values'] = [''] + values
            value_combo.set('')
        self.apply_all_filters()

    def on_param_value_change(self, param_combo, value_combo):
        self.apply_all_filters()

    def collect_param_filters(self):
        """
        Считывает все выбранные фильтры параметров из интерфейса,
        возвращает dict параметр: значение для фильтра; фильтры без значения игнорируются
        """
        res = {}
        # Если параметр повторяется в нескольких фильтрах — логично использовать ИЛИ, но здесь реализуем И (последовательно)
        for row in self.param_filter_rows:
            p = row['param'].get()
            v = row['value'].get()
            if p and v:
                res.setdefault(p, set()).add(v)
        return res

    def apply_all_filters(self):
        """
        Применить фильтры:
        - поиск по артикулу (фр строка)
        - фильтр по параметрам (несколько параметров, каждое с набором значений)
        Все фильтры объединяются И: артикул + все выбранные параметры (каждый параметр — ИЛИ по выбранным значениям).
        """
        if self.filtered_df_backup.empty:
            return

        df = self.filtered_df_backup.copy()

        # Фильтр по артикулу (быстрый поиск)
        q = self.var_quick_search.get().lower().strip()
        if q:
            df = df[df['Артикул'].str.lower().str.contains(q, na=False)]

        param_filters = self.collect_param_filters()
        if param_filters:
            filtered_arts = []
            for art in df['Артикул']:
                info = self.grouped_data.get(art)
                if not info:
                    continue
                all_match = True
                for p, vals in param_filters.items():
                    # Проверяем, что параметры хотя бы одного из sections соответствуют одному из vals
                    match = False
                    for section in info['sections']:
                        for param_dict in section['params']:
                            if param_dict['parameter'] == p and param_dict['value'] in vals:
                                match = True
                                break
                        if match:
                            break
                    if not match:
                        all_match = False
                        break
                if all_match:
                    filtered_arts.append(art)
            df = df[df['Артикул'].isin(filtered_arts)]

        self.filtered_df = df
        self.update_data_table()

    def load_excel_dialog(self):
        path = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel Files", "*.xlsx *.xls")])
        if not path:
            return
        try:
            self.grouped_data = load_and_transform_excel_grouped(path)

            # Собираем все параметры и значения
            param_dict = {}
            for info in self.grouped_data.values():
                for section in info['sections']:
                    for param in section['params']:
                        pname, pval = param['parameter'], param['value']
                        if pname not in param_dict:
                            param_dict[pname] = set()
                        param_dict[pname].add(pval)
            self.all_params = sorted(param_dict.keys())
            self.param_values = dict((k, sorted(v)) for k, v in param_dict.items())

            # Обновляем выпадающие списки для фильтров в существующих строках (если есть)
            for row in self.param_filter_rows:
                row['param']['values'] = self.all_params
                # Если выбран параметр — обновляем значения под него
                p = row['param'].get()
                if p and p in self.param_values:
                    row['value']['values'] = [''] + self.param_values[p]
                else:
                    row['value']['values'] = []
                row['value'].set('')

            rows = []
            for art, info in self.grouped_data.items():
                obj = info["object_full"]
                ptype = info["product_type"]
                rows.append({"Объект": obj, "Тип товара": ptype, "Артикул": art})
            self.filtered_df = pd.DataFrame(rows)
            self.filtered_df_backup = self.filtered_df.copy()
            self.apply_all_filters()

            self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])
            self.update_basket_table()

            self.create_placeholders_from_loaded_products()  # Обновление плейсхолдеров после загрузки данных

            messagebox.showinfo("Успех", f"Данные загружены: {len(self.filtered_df_backup)} записей")
            logger.log(f"Файл загружен: {path}, записей: {len(self.filtered_df_backup)}")
        except Exception as e:
            traceback.print_exc()
            messagebox.showerror("Ошибка загрузки", str(e))
            logger.log(f"Ошибка загрузки: {e}")

    def update_data_table(self):
        self.tree_data.delete(*self.tree_data.get_children())
        if self.filtered_df.empty:
            self.tree_data["columns"] = []
            return
        self.tree_data["columns"] = list(self.filtered_df.columns)
        for col in self.filtered_df.columns:
            self.tree_data.heading(col, text=col)
            self.tree_data.column(col, width=160, anchor="w")
        for _, row in self.filtered_df.iterrows():
            self.tree_data.insert("", "end", values=list(row))

    def quick_search(self, event=None):
        self.apply_all_filters()

    def show_item_params(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            return
        iid = sel[0]
        item = self.tree_data.item(iid)
        art = item["values"][2]
        if art in self.grouped_data:
            if self.grouped_data[art]["sections"]:
                params = self.grouped_data[art]["sections"][0]["params"]
            else:
                params = []
            self.tree_params.delete(*self.tree_params.get_children())
            for param in params:
                self.tree_params.insert("", "end", values=(param["parameter"], param["value"]))

    def add_selected_to_basket(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            messagebox.showinfo("Добавление", "Выделите записи для добавления в корзину")
            return
        rows = [self.tree_data.item(iid)["values"] for iid in sel]
        df_add = pd.DataFrame(rows, columns=["Объект", "Тип товара", "Артикул"])
        self.basket_df = pd.concat([self.basket_df, df_add], ignore_index=True)
        self.basket_df.drop_duplicates(inplace=True)
        self.basket_df.reset_index(drop=True, inplace=True)
        self.update_basket_table()
        logger.log(f"В корзину добавлено: {len(rows)} записей")

    def update_basket_table(self):
        self.tree_basket.delete(*self.tree_basket.get_children())
        if self.basket_df.empty:
            self.tree_basket["columns"] = []
            return
        self.tree_basket["columns"] = list(self.basket_df.columns)
        for col in self.basket_df.columns:
            self.tree_basket.heading(col, text=col)
            self.tree_basket.column(col, width=160, anchor="w")
        for _, row in self.basket_df.iterrows():
            self.tree_basket.insert("", "end", values=list(row))

    def remove_selected_from_basket(self):
        sel = self.tree_basket.selection()
        if not sel:
            messagebox.showinfo("Удаление", "Выделите записи для удаления из корзины")
            return
        idxs = [self.tree_basket.index(iid) for iid in sel]
        self.basket_df.drop(self.basket_df.index[idxs], inplace=True)
        self.basket_df.reset_index(drop=True, inplace=True)
        self.update_basket_table()
        logger.log(f"Удалено из корзины: {len(idxs)} записей")

    def clear_basket(self):
        if messagebox.askyesno("Очистка корзины", "Очистить корзину?"):
            self.basket_df = pd.DataFrame(columns=["Объект", "Тип товара", "Артикул"])
            self.update_basket_table()
            logger.log("Корзина очищена")

    def context_menu_data(self, event):
        iid = self.tree_data.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Добавить в корзину", command=self.add_selected_to_basket)
        menu.post(event.x_root, event.y_root)

    def context_menu_basket(self, event):
        iid = self.tree_basket.identify_row(event.y)
        if not iid:
            return
        menu = tk.Menu(self, tearoff=0)
        menu.add_command(label="Удалить из корзины", command=self.remove_selected_from_basket)
        menu.post(event.x_root, event.y_root)

    def preview_template(self):
        if not self.current_template:
            messagebox.showwarning("Предпросмотр", "Шаблон не выбран")
            return
        path = os.path.join(TEMPLATES_DIR, self.current_template)
        try:
            if os.name == 'nt':
                os.startfile(path)
            else:
                try:
                    subprocess.Popen(["open", path])
                except:
                    subprocess.Popen(["xdg-open", path])
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка предпросмотра шаблона:\n{e}")

    def save_profile(self):
        try:
            self.save_gui_to_settings()
            if not self.selected_profile:
                prof_name = simpledialog.askstring("Сохранить профиль", "Имя профиля (без расширения):", parent=self)
                if not prof_name:
                    messagebox.showinfo("Отмена", "Сохранение отменено")
                    return
                fname = prof_name.strip() + ".json"
            else:
                fname = self.selected_profile
            if settings.save_profile(fname):
                self.selected_profile = fname
                self.load_profiles()
                self.combo_profiles.set(fname)
                messagebox.showinfo("Успех", f"Профиль {fname} сохранён")
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка сохранения профиля:\n{e}")

    def load_profile(self):
        prof = self.combo_profiles.get()
        if not prof:
            return
        if settings.load_profile(prof):
            self.selected_profile = prof
            self.load_settings_to_gui()
            messagebox.showinfo("Успех", f"Профиль {prof} загружен")

    def load_profiles(self):
        if not os.path.exists(PROFILES_DIR):
            os.makedirs(PROFILES_DIR)
        files = sorted([f for f in os.listdir(PROFILES_DIR) if f.endswith('.json')])
        self.combo_profiles['values'] = files
        if files and not self.selected_profile:
            self.combo_profiles.set(files[0])
            self.selected_profile = files[0]

    def on_profile_change(self, event=None):
        selected = self.combo_profiles.get()
        if selected:
            self.selected_profile = selected
            if settings.load_profile(selected):
                self.load_settings_to_gui()
                messagebox.showinfo("Профиль", f"Профиль '{selected}' загружен")
            else:
                messagebox.showerror("Ошибка", f"Ошибка загрузки профиля: {selected}")

    def on_template_change(self, event=None):
        selected = self.combo_templates.get()
        if selected:
            self.current_template = selected
            logger.log(f"Выбран шаблон: {selected}")

    def load_placeholders_to_tree(self):
        self.user_placeholders = placeholders.placeholders.copy()
        self.refresh_placeholders_view()

    def refresh_placeholders_view(self):
        self.tree_placeholders.delete(*self.tree_placeholders.get_children())
        for k, v in self.user_placeholders.items():
            self.tree_placeholders.insert("", "end", values=(k, v))

    def add_placeholder(self):
        name = simpledialog.askstring("Добавить плейсхолдер", "Имя плейсхолдера:", parent=self)
        if not name:
            return
        if name in self.user_placeholders:
            messagebox.showerror("Ошибка", "Такой плейсхолдер уже существует")
            return
        value = simpledialog.askstring("Добавить плейсхолдер", "Значение плейсхолдера:", parent=self)
        if value is None:
            return
        self.user_placeholders[name] = value
        placeholders.placeholders = self.user_placeholders.copy()
        placeholders.save()
        self.refresh_placeholders_view()
        logger.log(f"Добавлен плейсхолдер: {name} = {value}")

    def edit_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Редактировать", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name, old_value = self.tree_placeholders.item(iid, "values")
        new_value = simpledialog.askstring("Редактировать плейсхолдер", f"Значение плейсхолдера '{name}':", initialvalue=old_value, parent=self)
        if new_value is None:
            return
        self.user_placeholders[name] = new_value
        placeholders.placeholders = self.user_placeholders.copy()
        placeholders.save()
        self.refresh_placeholders_view()
        logger.log(f"Изменён плейсхолдер: {name} = {new_value}")

    def delete_placeholder(self):
        sel = self.tree_placeholders.selection()
        if not sel:
            messagebox.showinfo("Удалить", "Выделите плейсхолдер")
            return
        iid = sel[0]
        name = self.tree_placeholders.item(iid, "values")[0]
        if messagebox.askyesno("Удалить плейсхолдер", f"Удалить плейсхолдер '{name}'?"):
            self.user_placeholders.pop(name, None)
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            logger.log(f"Удалён плейсхолдер: {name}")

    def import_placeholders(self):
        path = filedialog.askopenfilename(title="Импорт плейсхолдеров из JSON", filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'r', encoding='utf-8') as f:
                d = json.load(f)
            if not isinstance(d, dict):
                raise ValueError("Файл должен содержать словарь плейсхолдеров")
            self.user_placeholders.update(d)
            placeholders.placeholders = self.user_placeholders.copy()
            placeholders.save()
            self.refresh_placeholders_view()
            messagebox.showinfo("Импорт", f"Импортировано {len(d)} плейсхолдеров")
            logger.log(f"Импортировано плейсхолдеров: {len(d)}")
        except Exception as e:
            messagebox.showerror("Ошибка импорта", str(e))
            logger.log(f"Ошибка импорта плейсхолдеров: {e}")

    def export_placeholders(self):
        path = filedialog.asksaveasfilename(title="Экспорт плейсхолдеров в JSON", defaultextension=".json",
                                            filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, 'w', encoding='utf-8') as f:
                json.dump(self.user_placeholders, f, ensure_ascii=False, indent=4)
            messagebox.showinfo("Экспорт", f"Плейсхолдеры экспортированы: {path}")
            logger.log(f"Плейсхолдеры экспортированы: {path}")
        except Exception as e:
            messagebox.showerror("Ошибка экспорта", str(e))
            logger.log(f"Ошибка экспорта плейсхолдеров: {e}")

    def export_log(self):
        path = filedialog.asksaveasfilename(title="Сохранить лог", defaultextension=".txt",
                                            filetypes=[("Text files", "*.txt")])
        if not path:
            return
        logger.save_to_file(path)
        messagebox.showinfo("Лог сохранён", f"Лог сохранён в файл:\n{path}")

    def save_gui_to_settings(self):
        d = settings.data
        exp = d.setdefault("export", {})
        exp["format"] = self.combo_export_format.get() if hasattr(self, "combo_export_format") else "docx"
        exp["compress_images"] = self.var_compress_images.get() if hasattr(self, "var_compress_images") else True
        exp["pdf_password"] = self.entry_pdf_password.get() if hasattr(self, "entry_pdf_password") else ""
        exp["template"] = self.combo_templates.get() if hasattr(self, "combo_templates") else None

        ts = d.setdefault("table_style", {})
        ts["header_bold"] = self.var_header_bold.get() if hasattr(self, "var_header_bold") else True
        ts["alignment"] = self.combo_align.get() if hasattr(self, "combo_align") else "center"
        ts["font_name"] = self.entry_font_name.get() if hasattr(self, "entry_font_name") else "Calibri"
        try:
            ts["font_size"] = int(self.spin_font_size.get()) if hasattr(self, "spin_font_size") else 11
        except:
            ts["font_size"] = 11

        ts["border_style"] = self.combo_border_style.get() if hasattr(self, "combo_border_style") else "тонкая"

        ts["section_header_font_name"] = self.entry_sec_header_font_name.get() if hasattr(self, "entry_sec_header_font_name") else "Calibri"
        try:
            ts["section_header_font_size"] = int(self.spin_sec_header_font_size.get()) if hasattr(self, "spin_sec_header_font_size") else 12
        except:
            ts["section_header_font_size"] = 12
        ts["section_header_bold"] = self.var_sec_header_bold.get() if hasattr(self, "var_sec_header_bold") else True
        ts["section_header_align"] = self.combo_sec_header_align.get() if hasattr(self, "combo_sec_header_align") else "center"
        ts["section_header_bg_color"] = self.entry_sec_header_bg_color.get() if hasattr(self, "entry_sec_header_bg_color") else "FFFF00"

        settings.save()

    def load_settings_to_gui(self):
        d = settings.data
        ex = d.get("export", {})
        ts = d.get("table_style", {})

        if hasattr(self, "combo_export_format"):
            self.combo_export_format.set(ex.get("format", "docx"))
        if hasattr(self, "var_compress_images"):
            self.var_compress_images.set(ex.get("compress_images", True))
        if hasattr(self, "entry_pdf_password"):
            self.entry_pdf_password.delete(0, "end")
            self.entry_pdf_password.insert(0, ex.get("pdf_password", ""))

        if hasattr(self, "var_header_bold"):
            self.var_header_bold.set(ts.get("header_bold", True))
        if hasattr(self, "combo_align"):
            self.combo_align.set(ts.get("alignment", "center"))
        if hasattr(self, "entry_font_name"):
            self.entry_font_name.delete(0, "end")
            self.entry_font_name.insert(0, ts.get("font_name", "Calibri"))
        if hasattr(self, "spin_font_size"):
            self.spin_font_size.delete(0, "end")
            self.spin_font_size.insert(0, ts.get("font_size", 11))
        if hasattr(self, "combo_border_style"):
            self.combo_border_style.set(ts.get("border_style", "тонкая"))

        if hasattr(self, "entry_sec_header_font_name"):
            self.entry_sec_header_font_name.delete(0, "end")
            self.entry_sec_header_font_name.insert(0, ts.get("section_header_font_name", "Calibri"))
        if hasattr(self, "spin_sec_header_font_size"):
            self.spin_sec_header_font_size.delete(0, "end")
            self.spin_sec_header_font_size.insert(0, ts.get("section_header_font_size", 12))
        if hasattr(self, "var_sec_header_bold"):
            self.var_sec_header_bold.set(ts.get("section_header_bold", True))
        if hasattr(self, "combo_sec_header_align"):
            self.combo_sec_header_align.set(ts.get("section_header_align", "center"))
        if hasattr(self, "entry_sec_header_bg_color"):
            self.entry_sec_header_bg_color.delete(0, "end")
            self.entry_sec_header_bg_color.insert(0, ts.get("section_header_bg_color", "FFFF00"))

        if hasattr(self, "combo_templates"):
            self.load_templates()
            tmpl = ex.get("template")
            if self.templates:
                if tmpl in self.templates:
                    self.combo_templates.set(tmpl)
                    self.current_template = tmpl
                else:
                    self.combo_templates.set(self.templates[0])
                    self.current_template = self.templates[0]

    def load_templates(self):
        self.templates = sorted([f for f in os.listdir(TEMPLATES_DIR) if f.lower().endswith(".docx")])
        if hasattr(self, "combo_templates"):
            self.combo_templates["values"] = self.templates
            if self.templates:
                if self.current_template not in self.templates:
                    self.combo_templates.set(self.templates[0])
                    self.current_template = self.templates[0]

    def generate_document(self, path=None, show_messages=True):
        if self.basket_df.empty:
            if show_messages:
                messagebox.showwarning("Генерация", "Корзина пуста")
            return
        if not self.current_template:
            if show_messages:
                messagebox.showwarning("Генерация", "Шаблон не выбран")
            return

        self.save_gui_to_settings()

        save_dir = None
        if path is None:
            save_dir = filedialog.askdirectory(title="Выберите папку для сохранения документов")
            if not save_dir:
                return
        else:
            save_dir = os.path.dirname(path)

        try:
            template_path = os.path.join(TEMPLATES_DIR, self.current_template)
            phs_global = self.user_placeholders.copy()
            unique_arts = list(dict.fromkeys(self.basket_df["Артикул"]))

            for art in unique_arts:
                doc = Document(template_path)

                if art in self.grouped_data:
                    info = self.grouped_data[art]
                    art_info = {
                        "Объект": info["object_full"],
                        "Тип товара": info["product_type"],
                        "Артикул": art,
                    }
                    art_sections = info["sections"]
                else:
                    art_info = {"Объект": "", "Тип товара": "", "Артикул": art}
                    art_sections = []

                phs = dict(phs_global)
                phs.update(art_info)

                # --- Расширенный вывод плейсхолдеров параметров ---
                # Для каждого параметра загруженного артикула добавим значение для замены плейсхолдера с именем параметра
                # Так реализуется возможность использовать в шаблоне {{Параметр}} и получить соответствующее значение.
                # Если параметр встречается в нескольких секциях, берется первое совпадение.

                for section in art_sections:
                    for param in section["params"]:
                        pname = param["parameter"]
                        pvalue = param["value"]
                        # Если плейсхолдер параметра существует, заменяем значение
                        if pname in phs and phs[pname] == "{{" + pname + "}}":
                            phs[pname] = pvalue
                        # Если плейсхолдера нет, но в шаблоне такой используется, может не замениться,
                        # поэтому оставим только если пользователь сам добавлял, иначе пропускаем.

                for k, v in phs.items():
                    docx_replace_placeholder(doc, k, v)

                placeholder_paragraph = None
                for p in doc.paragraphs:
                    if "{{Таблица}}" in p.text:
                        placeholder_paragraph = p
                        break

                if placeholder_paragraph is None:
                    logger.log(f"Плейсхолдер {{Таблица}} не найден в шаблоне для артикула {art}")
                    if show_messages:
                        messagebox.showwarning("Генерация",
                                               f"Шаблон не содержит плейсхолдера {{Таблица}} для артикула {art}")
                    filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                    file_path = os.path.join(save_dir, filename)
                    doc.save(file_path)
                    continue

                style = settings.data.get("table_style", {})
                border_style = style.get("border_style", "тонкая")

                previous_element = placeholder_paragraph._element

                sh_font_name = style.get("section_header_font_name", "Calibri")
                sh_font_size = style.get("section_header_font_size", 12)
                sh_bold = style.get("section_header_bold", True)
                sh_align = style.get("section_header_align", "center")
                sh_bg_color = style.get("section_header_bg_color", "FFFF00")

                for section in art_sections:
                    tbl = doc.add_table(rows=1, cols=2)
                    set_table_border(tbl, border_style)

                    row = tbl.rows[0]
                    cell = row.cells[0]
                    cell.merge(row.cells[1])
                    cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER

                    paragraph = cell.paragraphs[0]
                    paragraph.alignment = {
                        "left": WD_ALIGN_PARAGRAPH.LEFT,
                        "center": WD_ALIGN_PARAGRAPH.CENTER,
                        "right": WD_ALIGN_PARAGRAPH.RIGHT,
                    }.get(sh_align, WD_ALIGN_PARAGRAPH.CENTER)
                    for r in paragraph.runs:
                        r.text = ''
                    run = paragraph.add_run(section.get("header", ""))
                    run.font.name = sh_font_name
                    run.font.size = Pt(sh_font_size)
                    run.bold = sh_bold
                    set_cell_background(cell, sh_bg_color)

                    for param in section["params"]:
                        row_cells = tbl.add_row().cells
                        row_cells[0].text = str(param["parameter"])
                        row_cells[1].text = str(param["value"])
                        for i in (0, 1):
                            p_cell = row_cells[i].paragraphs[0]
                            p_cell.style = None
                            for r in p_cell.runs:
                                r.font.name = style.get("font_name", "Calibri")
                                r.font.size = Pt(style.get("font_size", 11))
                                r.bold = False
                            p_cell.alignment = {
                                "left": WD_ALIGN_PARAGRAPH.LEFT,
                                "right": WD_ALIGN_PARAGRAPH.RIGHT,
                                "center": WD_ALIGN_PARAGRAPH.CENTER,
                            }.get(style.get("alignment", "center"), WD_ALIGN_PARAGRAPH.CENTER)

                    previous_element.addnext(tbl._element)
                    previous_element = tbl._element

                p = placeholder_paragraph._element
                p.getparent().remove(p)

                filename = f"{datetime.now().strftime('%Y-%m-%d')}-паспорт IDIS-{art}.docx"
                file_path = os.path.join(save_dir, filename)
                doc.save(file_path)
                logger.log(f"Создан файл для артикула {art}: {file_path}")

                if settings.data.get("export", {}).get("format") == "pdf":
                    pdf_path = os.path.splitext(file_path)[0] + ".pdf"
                    if WIN32COM_AVAILABLE:
                        convert_docx_to_pdf(file_path, pdf_path)
                        logger.log(f"PDF создан для артикула {art}: {pdf_path}")
                    else:
                        logger.log("Для создания PDF необходим MS Word и pywin32")

            if show_messages:
                messagebox.showinfo("Успех", f"Обработано товаров: {len(unique_arts)}\nФайлы сохранены в:\n{save_dir}")

        except Exception as e:
            traceback.print_exc()
            logger.log(f"Ошибка генерации документа: {e}")
            if show_messages:
                messagebox.showerror("Ошибка генерации", str(e))


def convert_docx_to_pdf(docx_path, pdf_path):
    word = win32com.client.Dispatch("Word.Application")
    word.Visible = False
    doc = word.Documents.Open(docx_path)
    doc.SaveAs(pdf_path, FileFormat=17)
    doc.Close()
    word.Quit()


def main():
    app = App()
    app.mainloop()


if __name__ == '__main__':
    main()

Добавь поиск в блоке Плейсхолдеры, чтобы можно было найти по имени.
Сделай автоматическую сортировка от А до Я по имени плейсхолдера. Чтобы они сразу показывались от А до Я.