import os
import sys
import subprocess
import tempfile
import customtkinter as ctk
from tkinter import filedialog, messagebox
from pathlib import Path
from typing import Optional
import threading

# ----- Настройки темы -----
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue")

# ----- Расширения по типам проектов -----
PROJECT_PRESETS = {
    "Python":      {".py", ".pyw", ".pyi", ".toml", ".cfg", ".ini", ".txt", ".md", ".yml", ".yaml"},
    "Web (HTML/CSS/JS)": {".html", ".htm", ".css", ".scss", ".sass", ".js", ".jsx", ".ts", ".tsx", ".json", ".svg"},
    "C/C++":       {".c", ".cpp", ".cc", ".h", ".hpp", ".cmake", ".txt", ".md"},
    "Java":        {".java", ".xml", ".gradle", ".properties", ".md"},
    "Go":          {".go", ".mod", ".sum", ".md"},
    "Rust":        {".rs", ".toml", ".md"},
    "AI Prompts":  {".md", ".txt"},
    "Все текстовые": {".py", ".js", ".ts", ".html", ".css", ".json", ".xml",
                      ".java", ".c", ".cpp", ".h", ".hpp", ".go", ".rs",
                      ".md", ".txt", ".yml", ".yaml", ".toml", ".cfg", ".ini",
                      ".sh", ".bat", ".sql", ".php", ".rb"},
}

# Сопоставление расширений и языков для Markdown-подсветки
LANG_MAP = {
    ".py": "python", ".pyw": "python", ".pyi": "python",
    ".js": "javascript", ".jsx": "jsx", ".mjs": "javascript",
    ".ts": "typescript", ".tsx": "tsx",
    ".html": "html", ".htm": "html",
    ".css": "css", ".scss": "scss", ".sass": "sass",
    ".json": "json", ".xml": "xml", ".svg": "xml",
    ".yml": "yaml", ".yaml": "yaml",
    ".toml": "toml", ".ini": "ini", ".cfg": "ini",
    ".c": "c", ".h": "c",
    ".cpp": "cpp", ".cc": "cpp", ".hpp": "cpp",
    ".java": "java",
    ".go": "go", ".mod": "go",
    ".rs": "rust",
    ".sh": "bash", ".bat": "batch",
    ".sql": "sql", ".php": "php", ".rb": "ruby",
    ".md": "markdown", ".txt": "text",
}

# Маркер-файлы (имя -> тип проекта)
PROJECT_MARKERS = {
    "pyproject.toml":   "Python",
    "requirements.txt": "Python",
    "setup.py":         "Python",
    "Pipfile":          "Python",
    "manage.py":        "Python",

    "package.json":      "Web (HTML/CSS/JS)",
    "index.html":        "Web (HTML/CSS/JS)",
    "vite.config.js":    "Web (HTML/CSS/JS)",
    "vite.config.ts":    "Web (HTML/CSS/JS)",
    "webpack.config.js": "Web (HTML/CSS/JS)",
    "tsconfig.json":     "Web (HTML/CSS/JS)",

    "Cargo.toml":       "Rust",
    "go.mod":           "Go",
    "pom.xml":          "Java",
    "build.gradle":     "Java",
    "build.gradle.kts": "Java",
    "CMakeLists.txt":   "C/C++",
    "Makefile":         "C/C++",
}

# Расширения -> тип (для подсчёта)
EXT_TO_TYPE = {
    ".py": "Python", ".pyw": "Python",
    ".html": "Web (HTML/CSS/JS)", ".htm": "Web (HTML/CSS/JS)",
    ".css": "Web (HTML/CSS/JS)", ".scss": "Web (HTML/CSS/JS)",
    ".js": "Web (HTML/CSS/JS)", ".jsx": "Web (HTML/CSS/JS)",
    ".ts": "Web (HTML/CSS/JS)", ".tsx": "Web (HTML/CSS/JS)",
    ".c": "C/C++", ".h": "C/C++",
    ".cpp": "C/C++", ".cc": "C/C++", ".hpp": "C/C++",
    ".java": "Java",
    ".go": "Go",
    ".rs": "Rust",
}

# Папки, которые игнорируем
DEFAULT_IGNORE_DIRS = {
    "__pycache__", ".git", ".idea", ".vscode", "node_modules",
    "venv", ".venv", "env", "dist", "build", ".pytest_cache",
    ".mypy_cache", "target", "out", "bin", "obj",
}

MAX_FILE_SIZE_MB = 5
MIX_THRESHOLD = 0.15  # Порог для смешанного режима (15% файлов одного типа)
BINARY_PROBE_BYTES = 8192


class ProjectBundlerApp(ctk.CTk):
    def __init__(self):
        super().__init__()
        self.title("Сборщик проектов в один файл")
        self.geometry("880x760")
        self.minsize(760, 600)
        self.resizable(True, True)

        self.project_path = ctk.StringVar()
        self.output_path  = ctk.StringVar()
        self.preset       = ctk.StringVar(value="Авто")
        self.format_var   = ctk.StringVar(value="Markdown (.md)")

        self.last_output = None

        self._build_ui()

    # ============================================================
    # UI
    # ============================================================
    def _build_ui(self):
        title = ctk.CTkLabel(
            self, text="Сборщик проектов",
            font=ctk.CTkFont(size=26, weight="bold"),
        )
        title.pack(pady=(20, 4))

        subtitle = ctk.CTkLabel(
            self, text="Объединяет файлы проекта в один файл с деревом структуры и подсветкой",
            font=ctk.CTkFont(size=13), text_color="gray70",
        )
        subtitle.pack(pady=(0, 16))

        # ---------- Карточка настроек ----------
        card = ctk.CTkFrame(self, corner_radius=12)
        card.pack(fill="x", padx=20, pady=10)

        # Папка проекта
        ctk.CTkLabel(card, text="Папка проекта:", anchor="w").grid(
            row=0, column=0, columnspan=2, sticky="w", padx=15, pady=(15, 4))
        ctk.CTkEntry(card, textvariable=self.project_path, height=36).grid(
            row=1, column=0, sticky="ew", padx=(15, 8), pady=(0, 10))
        ctk.CTkButton(card, text="Обзор...", width=130, height=36,
                      command=self.choose_project).grid(
            row=1, column=1, padx=(0, 15), pady=(0, 10))

        # Выходной файл
        ctk.CTkLabel(card, text="Выходной файл:", anchor="w").grid(
            row=2, column=0, columnspan=2, sticky="w", padx=15, pady=(5, 4))
        ctk.CTkEntry(card, textvariable=self.output_path, height=36).grid(
            row=3, column=0, sticky="ew", padx=(15, 8), pady=(0, 10))
        ctk.CTkButton(card, text="Сохранить как...", width=130, height=36,
                      command=self.choose_output).grid(
            row=3, column=1, padx=(0, 15), pady=(0, 10))

        # Тип проекта + кнопка определения + формат
        opts = ctk.CTkFrame(card, fg_color="transparent")
        opts.grid(row=4, column=0, columnspan=2, sticky="ew", padx=15, pady=(5, 15))
        opts.grid_columnconfigure(0, weight=2)
        opts.grid_columnconfigure(1, weight=0)
        opts.grid_columnconfigure(2, weight=2)

        ctk.CTkLabel(opts, text="Тип проекта:", anchor="w").grid(
            row=0, column=0, sticky="w", pady=(0, 4))
        ctk.CTkLabel(opts, text="Формат вывода:", anchor="w").grid(
            row=0, column=2, sticky="w", padx=(10, 0), pady=(0, 4))

        ctk.CTkOptionMenu(
            opts, values=["Авто"] + list(PROJECT_PRESETS.keys()),
            variable=self.preset, height=36,
        ).grid(row=1, column=0, sticky="ew", padx=(0, 5))

        ctk.CTkButton(
            opts, text="Определить", width=110, height=36,
            fg_color="gray30", hover_color="gray40",
            command=self.detect_now,
        ).grid(row=1, column=1, padx=5)

        ctk.CTkOptionMenu(
            opts, values=["Markdown (.md)", "Текст (.txt)"],
            variable=self.format_var, height=36,
            command=self._on_format_change,
        ).grid(row=1, column=2, sticky="ew", padx=(5, 0))

        card.grid_columnconfigure(0, weight=1)

        # ---------- Кнопка запуска ----------
        self.run_btn = ctk.CTkButton(
            self, text="Собрать проект", height=44,
            font=ctk.CTkFont(size=15, weight="bold"),
            command=self.run_bundle,
        )
        self.run_btn.pack(fill="x", padx=20, pady=(10, 6))

        # ---------- Прогресс ----------
        self.progress = ctk.CTkProgressBar(self, mode="determinate", corner_radius=0)
        self.progress.pack(fill="x", padx=20, pady=(6, 10))
        self.progress.set(0)

        # ---------- Кнопки результата ----------
        result_bar = ctk.CTkFrame(self, fg_color="transparent")
        result_bar.pack(fill="x", padx=20, pady=(0, 6))
        result_bar.grid_columnconfigure((0, 1), weight=1)

        self.open_file_btn = ctk.CTkButton(
            result_bar, text="Открыть результат", height=38,
            state="disabled", command=self.open_result_file,
        )
        self.open_file_btn.grid(row=0, column=0, sticky="ew", padx=(0, 5))

        self.open_folder_btn = ctk.CTkButton(
            result_bar, text="Открыть папку", height=38,
            state="disabled", fg_color="gray30", hover_color="gray40",
            command=self.open_result_folder,
        )
        self.open_folder_btn.grid(row=0, column=1, sticky="ew", padx=(5, 0))

        # ---------- Лог ----------
        ctk.CTkLabel(self, text="Лог:", anchor="w").pack(
            fill="x", padx=20, pady=(8, 2))
        self.log_box = ctk.CTkTextbox(self, height=180, font=("Consolas", 12))
        self.log_box.pack(fill="both", expand=True, padx=20, pady=(0, 20))

    # ============================================================
    # Обработчики UI
    # ============================================================
    def _on_format_change(self, _value):
        out = self.output_path.get().strip()
        if not out:
            return
        new_ext = ".md" if self.format_var.get().startswith("Markdown") else ".txt"
        p = Path(out)
        if p.suffix.lower() != new_ext:
            self.output_path.set(str(p.with_suffix(new_ext)))

    def choose_project(self):
        path = filedialog.askdirectory(title="Выберите папку проекта")
        if path:
            self.project_path.set(path)
            if not self.output_path.get():
                if self.preset.get() == "AI Prompts":
                    self.output_path.set(str(Path(path).parent / "prompt.md"))
                else:
                    name = Path(path).name or "project"
                    ext = ".md" if self.format_var.get().startswith("Markdown") else ".txt"
                    self.output_path.set(str(Path(path).parent / f"{name}_bundle{ext}"))

    def choose_output(self):
        is_md = self.format_var.get().startswith("Markdown")
        default_ext = ".md" if is_md else ".txt"
        types = [("Markdown", "*.md"), ("Текстовый файл", "*.txt"), ("Все файлы", "*.*")]
        if not is_md:
            types = [("Текстовый файл", "*.txt"), ("Markdown", "*.md"), ("Все файлы", "*.*")]
        path = filedialog.asksaveasfilename(
            title="Сохранить как", defaultextension=default_ext, filetypes=types,
        )
        if path:
            self.output_path.set(path)

    def log(self, msg):
        """Потокобезопасное логирование: планирует вставку в главном потоке."""
        self.after(0, self._append_log, msg)

    def _append_log(self, msg):
        self.log_box.insert("end", msg + "\n")
        self.log_box.see("end")

    def _ui_progress(self, value):
        self.after(0, lambda v=value: self.progress.set(v))

    def _ui_msg(self, kind, title, text):
        fn = {"info": messagebox.showinfo,
              "warn": messagebox.showwarning,
              "error": messagebox.showerror}[kind]
        self.after(0, lambda: fn(title, text))

    def detect_now(self):
        """Кнопка 'Определить' - показывает результат автоопределения."""
        project = self.project_path.get().strip()
        if not project or not Path(project).is_dir():
            messagebox.showerror("Ошибка", "Сначала укажите папку проекта.")
            return
        self.log_box.delete("1.0", "end")
        self.log("--- Определение типа проекта ---")
        types, _ext = self._detect_project_types(Path(project).resolve())

        if len(types) == 1:
            self.preset.set(types[0])
            messagebox.showinfo("Тип определён", f"Тип проекта: {types[0]}")
        else:
            # Смешанный — оставляем "Авто" в выпадающем списке
            self.preset.set("Авто")
            messagebox.showinfo(
                "Смешанный проект",
                f"Обнаружено несколько типов:\n\n• " + "\n• ".join(types) +
                "\n\nБудут использованы расширения всех найденных типов.",
            )

    # ============================================================
    # Открытие файла/папки
    # ============================================================
    def open_result_file(self):
        if not self.last_output or not Path(self.last_output).exists():
            messagebox.showwarning("Внимание", "Файл не найден.")
            return
        self._open_in_system(self.last_output)

    def open_result_folder(self):
        if not self.last_output:
            return
        self._open_in_system(str(Path(self.last_output).parent))

    @staticmethod
    def _open_in_system(path):
        try:
            if sys.platform.startswith("win"):
                os.startfile(path)
            elif sys.platform == "darwin":
                subprocess.run(["open", path], check=False)
            else:
                subprocess.run(["xdg-open", path], check=False)
        except Exception as e:
            messagebox.showerror("Ошибка", f"Не удалось открыть:\n{e}")

    # ============================================================
    # Автоопределение типа проекта (смешанный режим)
    # ============================================================
    def _detect_project_types(self, root: Path, all_files: Optional[list] = None):
        """
        Возвращает (types, extensions):
        - types: список найденных типов проекта (1 или несколько)
        - extensions: объединённый набор расширений

        all_files — опциональный кэш всех файлов (из _scan_once).
        При наличии подсчёт расширений идёт по кэшу без повторного os.walk.
        """
        found_types = set()

        # --- 1. Поиск маркер-файлов (в корне и на 1 уровень вглубь) ---
        for marker, ptype in PROJECT_MARKERS.items():
            if (root / marker).exists():
                if ptype not in found_types:
                    self.log(f"  [маркер] {marker} -> {ptype}")
                found_types.add(ptype)

        try:
            for sub in root.iterdir():
                if sub.is_dir() and sub.name not in DEFAULT_IGNORE_DIRS:
                    for marker, ptype in PROJECT_MARKERS.items():
                        if (sub / marker).exists():
                            if ptype not in found_types:
                                self.log(f"  [маркер] {sub.name}/{marker} -> {ptype}")
                            found_types.add(ptype)
        except PermissionError:
            pass

        # --- 2. Подсчёт расширений ---
        counts: dict[str, int] = {}
        total = 0
        if all_files is not None:
            # Используем кэш — без повторного обхода ФС
            for p in all_files:
                ext = p.suffix.lower()
                ptype = EXT_TO_TYPE.get(ext)
                if ptype:
                    counts[ptype] = counts.get(ptype, 0) + 1
                    total += 1
        else:
            for dirpath, dirnames, filenames in os.walk(root):
                dirnames[:] = [d for d in dirnames if d not in DEFAULT_IGNORE_DIRS]
                for name in filenames:
                    ext = Path(name).suffix.lower()
                    ptype = EXT_TO_TYPE.get(ext)
                    if ptype:
                        counts[ptype] = counts.get(ptype, 0) + 1
                        total += 1

        if counts:
            self.log(f"  [подсчёт] {counts} (всего {total})")
            for ptype, cnt in counts.items():
                share = cnt / total if total else 0
                if share >= MIX_THRESHOLD and ptype not in found_types:
                    self.log(f"    -> {ptype} ({share*100:.0f}%) добавлен")
                    found_types.add(ptype)

        # --- 3. Fallback ---
        if not found_types:
            self.log("  Тип не определён, использую 'Все текстовые'")
            return ["Все текстовые"], PROJECT_PRESETS["Все текстовые"]

        # --- 4. Сборка результата ---
        types_list = sorted(found_types)
        extensions = set()
        for t in types_list:
            extensions |= PROJECT_PRESETS[t]

        if len(types_list) == 1:
            self.log(f"  Итог: {types_list[0]}")
        else:
            self.log(f"  Итог (смешанный): {' + '.join(types_list)}")

        return types_list, extensions

    # ============================================================
    # Запуск сборки
    # ============================================================
    def run_bundle(self):
        project = self.project_path.get().strip()
        output  = self.output_path.get().strip()

        if not project or not Path(project).is_dir():
            messagebox.showerror("Ошибка", "Укажите корректную папку проекта.")
            return
        if not output:
            messagebox.showerror("Ошибка", "Укажите выходной файл.")
            return

        output_path = Path(output).resolve()
        if output_path.exists() and not messagebox.askyesno(
            "Подтверждение перезаписи",
            f"Файл уже существует:\n{output_path}\n\nЗаменить его?",
        ):
            return

        selected_preset = self.preset.get()
        is_md = self.format_var.get().startswith("Markdown")

        self.run_btn.configure(state="disabled", text="Собираем...")
        self.open_file_btn.configure(state="disabled")
        self.open_folder_btn.configure(state="disabled")
        self.log_box.delete("1.0", "end")
        self.progress.set(0)

        threading.Thread(
            target=self._bundle_worker,
            args=(project, output, selected_preset, is_md),
            daemon=True
        ).start()

    def _bundle_worker(self, project, output, selected, is_md):
        temp_output = None
        try:
            root = Path(project).resolve()
            output_path = Path(output).resolve()
            output_path.parent.mkdir(parents=True, exist_ok=True)

            # --- Определение типа ---
            if selected == "Авто":
                self.log("Режим: Авто-определение типа проекта")
                # Единый обход ФС: кэшируем все файлы, затем фильтруем
                self.log(f"\nСканирование: {root}")
                all_files = self._scan_once(root)
                types, extensions = self._detect_project_types(root, all_files)
                project_type_label = " + ".join(types)
                is_ai_prompts = (types == ["AI Prompts"])
                files = sorted(
                    p for p in all_files if p.suffix.lower() in extensions
                )
            else:
                extensions = PROJECT_PRESETS[selected]
                project_type_label = selected
                self.log(f"Режим: ручной выбор -> {selected}")
                is_ai_prompts = (selected == "AI Prompts")
                self.log(f"\nСканирование: {root}")
                files = self._collect_files(root, extensions)

            # Результат не должен включаться сам в себя при повторной сборке.
            files = [p for p in files if p.resolve() != output_path]
            self.log(f"Найдено файлов: {len(files)}")

            if not files:
                self.log("Нет подходящих файлов для сборки.")
                self._ui_msg("warn", "Внимание", "Подходящих файлов не найдено.")
                return

            fd, temp_name = tempfile.mkstemp(
                prefix=f".{output_path.name}.", suffix=".tmp",
                dir=output_path.parent,
            )
            temp_output = Path(temp_name)
            os.close(fd)

            # AI Prompts — отдельный формат вывода
            if is_ai_prompts:
                self._write_ai_prompts(temp_output, root, files)
            else:
                tree_str = self._build_tree(root, files)

                if is_md:
                    self._write_markdown(temp_output, root, files, tree_str, project_type_label)
                else:
                    self._write_text(temp_output, root, files, tree_str, project_type_label)

            os.replace(temp_output, output_path)
            temp_output = None

            self.last_output = str(output_path)
            self.log(f"\nГотово! Файл сохранен: {output_path}")

            self.after(0, lambda: self.open_file_btn.configure(state="normal"))
            self.after(0, lambda: self.open_folder_btn.configure(state="normal"))

            self._ui_msg("info", "Готово", f"Проект собран в:\n{output_path}")

        except Exception as e:
            self.log(f"ОШИБКА: {e}")
            self._ui_msg("error", "Ошибка", str(e))
        finally:
            if temp_output is not None:
                try:
                    temp_output.unlink(missing_ok=True)
                except OSError:
                    pass
            self.after(0, lambda: self.run_btn.configure(state="normal", text="Собрать проект"))
            self.after(1500, lambda: self.progress.set(0))  # сброс через 1.5 сек

    # ============================================================
    # Запись результата
    # ============================================================
    def _write_markdown(self, output, root, files, tree_str, project_type):
        with open(output, "w", encoding="utf-8") as out:
            out.write(f"# Project Bundle: `{root.name}`\n\n")
            out.write(f"- **Source:** `{root}`\n")
            out.write(f"- **Type:** {project_type}\n")
            out.write(f"- **Files:** {len(files)}\n\n")
            out.write("---\n\n")

            out.write("## Структура проекта\n\n")
            out.write("```text\n")
            out.write(tree_str)
            out.write("\n```\n\n")
            out.write("---\n\n")

            out.write("## Оглавление\n\n")
            # Предвычисляем уникальные якоря: дубли получают суффикс -1, -2...
            seen_anchors: dict[str, int] = {}
            anchors: dict[str, str] = {}
            for f in files:
                rel = f.relative_to(root).as_posix()
                base = self._make_anchor(rel)
                n = seen_anchors.get(base, 0)
                seen_anchors[base] = n + 1
                anchors[rel] = base if n == 0 else f"{base}-{n}"
            for f in files:
                rel = f.relative_to(root).as_posix()
                out.write(f"- [`{rel}`](#{anchors[rel]})\n")
            out.write("\n---\n\n")

            out.write("## Содержимое файлов\n\n")
            total = len(files)
            for i, file in enumerate(files, 1):
                rel = file.relative_to(root).as_posix()
                self.log(f"[{i}/{total}] {rel}")
                self._ui_progress(i / total)

                lang = LANG_MAP.get(file.suffix.lower(), "")
                anchor = anchors[rel]
                out.write(f'### <a id="{anchor}"></a>`{rel}`\n\n')

                try:
                    size_mb = file.stat().st_size / (1024 * 1024)
                    if size_mb > MAX_FILE_SIZE_MB:
                        out.write(f"> Пропущено: файл слишком большой ({size_mb:.2f} МБ)\n\n")
                        continue
                    if not self._is_text_file(file):
                        out.write("> Пропущено: бинарный файл\n\n")
                        continue
                    content = file.read_text(encoding="utf-8")

                    fence = "```"
                    while fence in content:
                        fence += "`"
                    out.write(f"{fence}{lang}\n")
                    out.write(content)
                    if not content.endswith("\n"):
                        out.write("\n")
                    out.write(f"{fence}\n\n")
                except Exception as e:
                    self.log(f"  Ошибка чтения {rel}: {e}")
                    out.write(f"> Ошибка чтения: {e}\n\n")

    def _write_text(self, output, root, files, tree_str, project_type):
        with open(output, "w", encoding="utf-8") as out:
            out.write("=" * 80 + "\n")
            out.write(f"PROJECT BUNDLE: {root.name}\n")
            out.write(f"Source: {root}\n")
            out.write(f"Type:   {project_type}\n")
            out.write(f"Files:  {len(files)}\n")
            out.write("=" * 80 + "\n\n")

            out.write("PROJECT TREE\n")
            out.write("-" * 80 + "\n")
            out.write(tree_str)
            out.write("\n" + "=" * 80 + "\n\n")

            out.write("FILES CONTENT\n")
            out.write("=" * 80 + "\n\n")

            total = len(files)
            for i, file in enumerate(files, 1):
                rel = file.relative_to(root).as_posix()
                self.log(f"[{i}/{total}] {rel}")
                self._ui_progress(i / total)

                out.write("#" * 80 + "\n")
                out.write(f"# FILE: {rel}\n")
                out.write("#" * 80 + "\n\n")

                try:
                    size_mb = file.stat().st_size / (1024 * 1024)
                    if size_mb > MAX_FILE_SIZE_MB:
                        out.write(f"[SKIPPED: file too large, {size_mb:.2f} MB]\n\n")
                        continue
                    if not self._is_text_file(file):
                        out.write("[SKIPPED: binary file]\n\n")
                        continue
                    content = file.read_text(encoding="utf-8")
                    out.write(content)
                    if not content.endswith("\n"):
                        out.write("\n")
                    out.write("\n")
                except Exception as e:
                    self.log(f"  Ошибка чтения {rel}: {e}")
                    out.write(f"[READ ERROR: {e}]\n\n")

    def _write_ai_prompts(self, output, root, files):
        """Формат AI Prompts: каждый файл обёрнут в <имя_файла>...</имя_файла>."""
        with open(output, "w", encoding="utf-8") as out:
            total = len(files)
            for i, file in enumerate(files, 1):
                rel = file.relative_to(root).as_posix()
                self.log(f"[{i}/{total}] {rel}")
                self._ui_progress(i / total)

                tag = file.name  # имя файла с расширением, например main.py

                try:
                    size_mb = file.stat().st_size / (1024 * 1024)
                    if size_mb > MAX_FILE_SIZE_MB:
                        self.log(f"  Пропущено (слишком большой): {rel}")
                        continue
                    content = file.read_text(encoding="utf-8", errors="replace")

                    out.write(f"<{tag}>\n")
                    out.write(content)
                    if not content.endswith("\n"):
                        out.write("\n")
                    out.write(f"</{tag}>\n")

                    # Пустая строка между блоками, кроме последнего
                    if i < total:
                        out.write("\n")

                except Exception as e:
                    self.log(f"  Ошибка чтения {rel}: {e}")

    # ============================================================
    # Утилиты
    # ============================================================
    @staticmethod
    def _make_anchor(path_str: str) -> str:
        """GitHub-style anchor для оглавления."""
        s = path_str.lower()
        out = []
        for ch in s:
            if ch.isalnum() or ch in "-_":
                out.append(ch)
            elif ch in " /.\\":
                out.append("-")
        anchor = "".join(out).strip("-")
        return anchor or "file"

    def _scan_once(self, root: Path) -> list:
        """Однократный обход ФС: возвращает все файлы (без фильтрации)."""
        result = []
        for dirpath, dirnames, filenames in os.walk(
            root, onerror=lambda e: self.log(f"Ошибка доступа: {e}")
        ):
            dirnames[:] = [d for d in dirnames if d not in DEFAULT_IGNORE_DIRS]
            for name in filenames:
                p = Path(dirpath) / name
                if self._is_safe_file(root, p):
                    result.append(p)
        return result

    def _collect_files(self, root: Path, extensions: set):
        """Рекурсивно собирает файлы с нужными расширениями."""
        result = []
        for dirpath, dirnames, filenames in os.walk(
            root, onerror=lambda e: self.log(f"Ошибка доступа: {e}")
        ):
            dirnames[:] = [d for d in dirnames if d not in DEFAULT_IGNORE_DIRS]
            for name in filenames:
                p = Path(dirpath) / name
                if self._is_safe_file(root, p) and p.suffix.lower() in extensions:
                    result.append(p)
        return sorted(result)

    def _is_safe_file(self, root: Path, path: Path) -> bool:
        """Разрешает только обычные файлы, физически находящиеся внутри проекта."""
        try:
            if path.is_symlink() or not path.resolve().is_relative_to(root):
                self.log(f"Пропущена небезопасная ссылка: {path}")
                return False
            return path.is_file()
        except OSError as e:
            self.log(f"Ошибка доступа: {path}: {e}")
            return False

    @staticmethod
    def _is_text_file(path: Path) -> bool:
        """Эвристически отбрасывает бинарные файлы перед чтением как текста."""
        try:
            with path.open("rb") as source:
                return b"\x00" not in source.read(BINARY_PROBE_BYTES)
        except OSError:
            return False

    def _build_tree(self, root: Path, files: list) -> str:
        """Строит ASCII-дерево только из собранных файлов."""
        rels = [f.relative_to(root) for f in files]
        tree: dict[Path, set] = {}
        dir_paths: set[Path] = set()  # явное множество директорий
        for rel in rels:
            parts = rel.parts
            for i in range(len(parts)):
                parent = Path(*parts[:i]) if i > 0 else Path(".")
                child = parts[i]
                tree.setdefault(parent, set()).add(child)
                if i < len(parts) - 1:
                    dir_paths.add(parent / child)

        lines = [f"{root.name}/"]

        def walk(current: Path, prefix: str):
            children = sorted(tree.get(current, []))
            dirs   = [c for c in children if (current / c) in dir_paths]
            filesc = [c for c in children if (current / c) not in dir_paths]
            ordered = dirs + filesc

            for i, name in enumerate(ordered):
                is_last = (i == len(ordered) - 1)
                connector = "└── " if is_last else "├── "
                full = current / name
                is_dir = full in dir_paths
                lines.append(prefix + connector + name + ("/" if is_dir else ""))
                if is_dir:
                    extension = "    " if is_last else "│   "
                    walk(full, prefix + extension)

        walk(Path("."), "")
        return "\n".join(lines)


if __name__ == "__main__":
    app = ProjectBundlerApp()
    app.mainloop()
                        
