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

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, ui_log_callback=None):
        self.history = []
        self.ui_log_callback = ui_log_callback

    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 self.ui_log_callback:
            self.ui_log_callback(record)

    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}")


class Settings:
    def __init__(self, logger=None):
        self.logger = logger
        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 _apply_defaults(self):
        exp = self.data.setdefault('export', {})
        exp.setdefault('format', 'docx')
        exp.setdefault('pdf_password', '')
        exp.setdefault('compress_images', True)

        ts = self.data.setdefault('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():
            ts.setdefault(k, v)

    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)
                self._apply_defaults()
                if self.logger:
                    self.logger.log("Настройки загружены")
            except Exception as e:
                if self.logger:
                    self.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)
            if self.logger:
                self.logger.log("Настройки сохранены")
        except Exception as e:
            if self.logger:
                self.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)
            if self.logger:
                self.logger.log(f"Профиль сохранён: {filename}")
            return True
        except Exception as e:
            if self.logger:
                self.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)
            self._apply_defaults()
            if self.logger:
                self.logger.log(f"Профиль загружен: {filename}")
            return True
        except Exception as e:
            if self.logger:
                self.logger.log(f"Ошибка загрузки профиля: {e}")
            return False


class Placeholders:
    def __init__(self, logger=None):
        self.placeholders = {}
        self.logger = logger
        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)
                if self.logger:
                    self.logger.log("Плейсхолдеры загружены")
            except Exception as e:
                if self.logger:
                    self.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)
            if self.logger:
                self.logger.log("Плейсхолдеры сохранены")
        except Exception as e:
            if self.logger:
                self.logger.log(f"Ошибка сохранения плейсхолдеров: {e}")


def split_by_idis(obj):
    if not isinstance(obj, str) or not obj.strip():
        return "", ""
    low = obj.lower()
    idx = low.find("idis")
    if idx >= 0:
        return obj[:idx].strip(), obj[idx + 4:].strip()
    return obj, ""


def extract_article_full(obj):
    _, article = split_by_idis(obj)
    if article:
        return article
    words = obj.strip().split()
    if len(words) <= 2:
        return obj.strip()
    return " ".join(words[-3:]).strip()


def extract_product_type(obj):
    product_type, _ = split_by_idis(obj)
    if product_type:
        return product_type
    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):
    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}
    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):
    borders_templates = {
        'нет': """
            <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>
        """,
        'тонкая': """
            <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>
        """,
        'толстая': """
            <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>
        """,
    }

    border_xml = borders_templates.get(border_style.lower(), borders_templates['тонкая']) % nsdecls('w')
    tblPr = table._element.tblPr

    # Remove existing borders
    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()
    # Remove existing shading if any
    for child in tcPr.findall(qn('w:shd')):
        tcPr.remove(child)
    shd = OxmlElement('w:shd')
    shd.set(qn('w:fill'), hex_color)
    tcPr.append(shd)


def convert_docx_to_pdf(docx_path, pdf_path):
    if not WIN32COM_AVAILABLE:
        raise RuntimeError("Для создания PDF необходим MS Word и pywin32")
    word = win32com.client.Dispatch("Word.Application")
    word.Visible = False
    # Open with ReadOnly=True to prevent locks
    doc = word.Documents.Open(docx_path, ReadOnly=True)
    doc.SaveAs(pdf_path, FileFormat=17)
    doc.Close()
    word.Quit()


class RightClickPasteMixin:
    """Миксин для добавления контекстного меню с вставкой в виджеты ввода."""

    @staticmethod
    def add_context_menu(widget):
        def show_context_menu(event):
            context_menu = tk.Menu(widget, tearoff=0)
            context_menu.add_command(label="Вставить", command=lambda w=widget: RightClickPasteMixin.paste_clipboard(w))
            context_menu.tk_popup(event.x_root, event.y_root)

        widget.bind("<Button-3>", show_context_menu)

    @staticmethod
    def paste_clipboard(widget):
        try:
            clipboard = widget.clipboard_get()
        except Exception:
            return
        if clipboard:
            try:
                if isinstance(widget, tk.Text):
                    widget.insert("insert", clipboard)
                elif isinstance(widget, (tk.Entry, ttk.Entry)):
                    try:
                        sel_first = widget.index("sel.first")
                        sel_last = widget.index("sel.last")
                        widget.delete(sel_first, sel_last)
                    except tk.TclError:
                        pass
                    insert_pos = widget.index("insert")
                    widget.insert(insert_pos, clipboard)
            except Exception:
                pass


class CtrlVPasteMixin:
    """Миксин для обработки Ctrl+V вставки для виджетов ввода."""

    def bind_ctrl_v(self):
        self.bind_all("<Control-v>", self.handle_ctrl_v)
        self.bind_all("<Control-V>", self.handle_ctrl_v)
        self.bind_all("<Control-KeyRelease>", self.handle_ctrl_v)

    def handle_ctrl_v(self, event):
        widget = event.widget
        try:
            clipboard = widget.clipboard_get()
        except Exception:
            return "break"

        if not clipboard:
            return "break"

        if isinstance(widget, tk.Text):
            try:
                widget.insert("insert", clipboard)
                return "break"
            except Exception:
                return "break"
        elif isinstance(widget, (tk.Entry, ttk.Entry)):
            try:
                try:
                    sel_first = widget.index("sel.first")
                    sel_last = widget.index("sel.last")
                    widget.delete(sel_first, sel_last)
                except tk.TclError:
                    pass
                insert_pos = widget.index("insert")
                widget.insert(insert_pos, clipboard)
                return "break"
            except Exception:
                return "break"
        else:
            return "break"


class App(tk.Tk, RightClickPasteMixin, CtrlVPasteMixin):
    def __init__(self):
        super().__init__()
        self.title("Автоматизация техдокументации")
        self.geometry("1200x720")
        self.minsize(1000, 600)

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

        self.logger = Logger(ui_log_callback=self.append_log_to_text_widget)
        self.settings = Settings(logger=self.logger)
        self.placeholders = Placeholders(logger=self.logger)

        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 = {}

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

        self.param_filter_rows = []

        self.var_quick_search = tk.StringVar()
        self.var_placeholder_search = tk.StringVar()
        self.var_header_bold = tk.BooleanVar()
        self.var_compress_images = tk.BooleanVar()
        self.var_sec_header_bold = tk.BooleanVar()

        # Добавленная переменная для S/N товара
        self.var_serial_number = tk.StringVar()

        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()

        # Добавляем дефолтный плейсхолдер "S/N товара", если его нет
        if "S/N товара" not in self.user_placeholders:
            self.user_placeholders["S/N товара"] = "{{S/N товара}}"
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()

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

        # Вызов для обработки Ctrl+V вставки
        self.bind_ctrl_v()

    def append_log_to_text_widget(self, record):
        pass

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

    def refresh_placeholders_view(self, placeholders_dict=None):
        if placeholders_dict is None:
            placeholders_dict = self.user_placeholders

        self.tree_placeholders.delete(*self.tree_placeholders.get_children())
        for k in sorted(placeholders_dict.keys(), key=lambda s: s.lower()):
            self.tree_placeholders.insert("", "end", values=(k, placeholders_dict[k]))

    def on_placeholder_search(self, event=None):
        query = self.var_placeholder_search.get().strip().lower()
        filtered = {}
        for k, v in self.user_placeholders.items():
            if query in k.lower():
                filtered[k] = v
        self.refresh_placeholders_view(filtered)

    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
        self.placeholders.placeholders = self.user_placeholders.copy()
        self.placeholders.save()
        self.refresh_placeholders_view()
        self.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
        self.placeholders.placeholders = self.user_placeholders.copy()
        self.placeholders.save()
        self.refresh_placeholders_view()
        self.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)
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()
            self.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)
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()
            messagebox.showinfo("Импорт", f"Импортировано {len(d)} плейсхолдеров")
            self.logger.log(f"Импортировано плейсхолдеров: {len(d)}")
        except Exception as e:
            messagebox.showerror("Ошибка импорта", str(e))
            self.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}")
            self.logger.log(f"Плейсхолдеры экспортированы: {path}")
        except Exception as e:
            messagebox.showerror("Ошибка экспорта", str(e))
            self.logger.log(f"Ошибка экспорта плейсхолдеров: {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 self.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 self.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 self.settings.load_profile(selected):
                self.load_settings_to_gui()
                messagebox.showinfo("Профиль", f"Профиль '{selected}' загружен")
            else:
                messagebox.showerror("Ошибка", f"Ошибка загрузки профиля: {selected}")

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

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

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

    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, postcommand=lambda: param_combo.configure(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 = 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):
        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 quick_search(self, event=None):
        self.apply_all_filters()

    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():
                    match = any(
                        param_dict['parameter'] == p and param_dict['value'] in vals
                        for section in info['sections']
                        for param_dict in section['params']
                    )
                    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 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 show_item_params(self, event=None):
        sel = self.tree_data.selection()
        if not sel:
            return
        iid = sel[0]
        item = self.tree_data.item(iid)
        if not item or not item.get("values"):
            return
        art = item["values"][2]
        if art in self.grouped_data:
            sections = self.grouped_data[art]["sections"]
            params = []
            if sections:
                for sec in sections:
                    params.extend(sec.get("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()
        self.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()
        self.logger.log(f"Удалено из корзины: {len(idxs)} записей")

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

    def save_gui_to_settings(self):
        d = self.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"

        self.settings.save()

    def load_settings_to_gui(self):
        d = self.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_excel_dialog(self):
        path = filedialog.askopenfilename(title="Выберите Excel файл", filetypes=[("Excel Files", "*.xlsx *.xls")])
        if not path:
            return

        def task():
            try:
                self.logger.log(f"Загрузка файла: {path}")
                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.after(0, self.update_basket_table)
                self.create_placeholders_from_loaded_products()

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

        threading.Thread(target=task, daemon=True).start()

    def create_placeholders_from_loaded_products(self):
        if not self.grouped_data:
            self.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:
            self.placeholders.placeholders = self.user_placeholders.copy()
            self.placeholders.save()
            self.refresh_placeholders_view()
            self.logger.log(f"Автоматически создано плейсхолдеров из параметров: {len(unique_params)}")
        else:
            self.logger.log("Автоматическое создание плейсхолдеров: новых плейсхолдеров не добавлено")

    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()

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

        serial_number_value = self.var_serial_number.get().strip()

        def task():
            try:
                template_path = os.path.join(TEMPLATES_DIR, self.current_template)
                phs_global = self.user_placeholders.copy()

                if "S/N товара" in phs_global:
                    if serial_number_value:
                        phs_global["S/N товара"] = serial_number_value
                    else:
                        phs_global["S/N товара"] = "{{S/N товара}}"
                else:
                    phs_global["S/N товара"] = serial_number_value if serial_number_value else "{{S/N товара}}"

                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 not placeholder_paragraph:
                        self.logger.log(f"Плейсхолдер {{Таблица}} не найден в шаблоне для артикула {art}")
                        if show_messages:
                            self.after(0, lambda art=art: 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 = self.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._element.rPr.rFonts.set(qn('w:eastAsia'), 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._element.rPr.rFonts.set(qn('w:eastAsia'), 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)
                    self.logger.log(f"Создан файл для артикула {art}: {file_path}")

                    if self.settings.data.get("export", {}).get("format") == "pdf":
                        pdf_path = os.path.splitext(file_path)[0] + ".pdf"
                        try:
                            convert_docx_to_pdf(file_path, pdf_path)
                            self.logger.log(f"PDF создан для артикула {art}: {pdf_path}")
                        except Exception as e:
                            self.logger.log(f"Ошибка при создании PDF для артикула {art}: {e}")

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

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

        threading.Thread(target=task, daemon=True).start()

    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.tk_popup(event.x_root, event.y_root)
        menu.grab_release()

    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.tk_popup(event.x_root, event.y_root)
        menu.grab_release()

    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_all("<Control-c>", self.handle_copy)
        self.bind_all("<Control-C>", self.handle_copy)
        self.bind_all("<Delete>", self.handle_delete)
        self.bind_all("<BackSpace>", self.handle_delete)

    def handle_copy(self, event):
        widget = event.widget
        try:
            if isinstance(widget, (tk.Entry, tk.Text, ttk.Entry)):
                if hasattr(widget, "selection_get"):
                    selection = widget.selection_get()
                else:
                    return
                self.clipboard_clear()
                self.clipboard_append(selection)
                return "break"
        except Exception:
            pass

    def handle_delete(self, event):
        widget = event.widget
        if isinstance(widget, (tk.Entry, tk.Text, ttk.Entry)):
            try:
                if widget.selection_present():
                    widget.delete("sel.first", "sel.last")
                else:
                    if event.keysym == 'Delete':
                        widget.delete(widget.index("insert"))
                    else:  # BackSpace
                        pos = widget.index("insert")
                        if pos > 0:
                            widget.delete(pos - 1)
                return "break"
            except Exception:
                pass

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

        self.create_tab_data()
        self.create_tab_settings_gen()
        self.create_basket_frame()

        # Добавляем контекстное меню ко всем полям ввода, чтобы правая кнопка вызывала вставку
        self.add_context_menu(self.entry_serial_number)
        self.add_context_menu(self.entry_font_name)
        self.add_context_menu(self.entry_sec_header_font_name)
        self.add_context_menu(self.entry_sec_header_bg_color)
        self.add_context_menu(self.entry_pdf_password)

    def create_tab_data(self):
        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")
        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")

        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 = []

        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("<<TreeviewSelect>>", 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)

    def create_basket_frame(self):
        basket_frame = ttk.LabelFrame(self, text="Корзина товаров")
        basket_frame.grid(row=1, column=0, sticky="nsew", padx=5, pady=5)
        self.grid_rowconfigure(1, weight=1)

        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)

    def create_tab_settings_gen(self):
        tab = ttk.Frame(self.nb)
        self.nb.add(tab, text="Настройки и Генерация")

        tab.grid_columnconfigure(0, weight=1)
        tab.grid_rowconfigure(7, weight=1)

        prof_frame = ttk.LabelFrame(tab, 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)

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

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

        sn_frame = ttk.Frame(tab)
        sn_frame.grid(row=4, column=0, sticky='ew', padx=10, pady=(0, 10))
        sn_frame.columnconfigure(1, weight=1)
        ttk.Label(sn_frame, text="S/N товара:").grid(row=0, column=0, sticky='w')
        self.entry_serial_number = ttk.Entry(sn_frame, textvariable=self.var_serial_number)
        self.entry_serial_number.grid(row=0, column=1, sticky='ew')

        ttk.Label(tab, text="Плейсхолдеры:").grid(row=5, column=0, sticky='w', padx=10, pady=(0, 3))

        ph_container = ttk.Frame(tab)
        ph_container.grid(row=6, column=0, sticky="nsew", padx=10)
        ph_container.grid_columnconfigure(0, weight=1)
        ph_container.grid_rowconfigure(1, weight=1)

        search_frame = ttk.Frame(ph_container)
        search_frame.grid(row=0, column=0, sticky="ew")
        search_frame.columnconfigure(1, weight=1)

        ttk.Label(search_frame, text="Поиск:").grid(row=0, column=0, sticky="w")
        ent_ph_search = ttk.Entry(search_frame, textvariable=self.var_placeholder_search)
        ent_ph_search.grid(row=0, column=1, sticky="ew")
        ent_ph_search.bind("<KeyRelease>", self.on_placeholder_search)

        tree_frame = ttk.Frame(ph_container)
        tree_frame.grid(row=1, column=0, sticky="nsew", pady=(5, 0))
        tree_frame.grid_rowconfigure(0, weight=1)
        tree_frame.grid_columnconfigure(0, weight=1)

        self.tree_placeholders = ttk.Treeview(tree_frame, columns=("name", "value"), show='headings')
        self.tree_placeholders.grid(row=0, column=0, sticky="nsew")
        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")

        scrollbar_ph = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree_placeholders.yview)
        scrollbar_ph.grid(row=0, column=1, sticky="ns")
        self.tree_placeholders.configure(yscrollcommand=scrollbar_ph.set)

        ph_btn_frame = ttk.Frame(tab)
        ph_btn_frame.grid(row=7, 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, text="Параметры таблицы:")
        style_label.grid(row=8, column=0, sticky='w', padx=10, pady=(10, 3))
        style_frm = ttk.Frame(tab)
        style_frm.grid(row=9, column=0, sticky="ew", padx=10, pady=5)
        style_frm.columnconfigure(10, weight=1)

        self.var_header_bold.set(self.settings.data['table_style'].get('header_bold', True))
        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(self.settings.data['table_style'].get('alignment', 'center'))
        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, self.settings.data['table_style'].get('font_name', 'Calibri'))
        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, self.settings.data['table_style'].get('font_size', 11))
        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(self.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, self.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, self.settings.data['table_style'].get("section_header_font_size", 12))

        self.var_sec_header_bold.set(self.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(self.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, self.settings.data['table_style'].get("section_header_bg_color", "FFFF00"))

        export_label = ttk.Label(tab, text="Настройки экспорта:")
        export_label.grid(row=10, column=0, sticky='w', padx=10, pady=(10, 3))
        export_frm = ttk.Frame(tab)
        export_frm.grid(row=11, 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(self.settings.data['export'].get('format', 'docx'))
        self.combo_export_format.grid(row=0, column=1, sticky="w", padx=5)

        self.var_compress_images.set(self.settings.data['export'].get('compress_images', True))
        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, self.settings.data['export'].get('pdf_password', ''))
        self.entry_pdf_password.grid(row=0, column=4, sticky="w", padx=5)

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

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


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


if __name__ == '__main__':
    main()

Внеси изменения в скрипт: 
1) если значение плейсхолдера отсутствует, то в генерируемом файле ничего выводить не нужно. Например, если не заполнено поле S/N товара, то в сгенерируемом файле в месте, где было указано {{S/N товара}} должно быть просто пусто.
2) при загрузке скрипта по-умолчанию всегда должны сразу подгружаться данные из файла .xlsx (или .xls), который будет лежать в папке, путь к которой будут указываться в настройках. Так не придется каждый раз выбирать папку с исходными данными.

Напиши полный скрипт с моими замечаниями.