import base64
import binascii
import os
import re
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

B64_RE = re.compile(r"[A-Za-z0-9+/]*={0,2}")
INVALID_NAME_CHARS = '<>:"/\\|?*'
MSG_NOT_B64 = "Данный текст не в формате Base64!"
MSG_B64_ERROR_SHORT = "Ошибка: некорректный Base64"


def strip_ws(text):
    return re.sub(r"\s+", "", text)


def normalize_b64(text):
    clean = strip_ws(text)
    if not clean:
        return None, "Поле с Base64-текстом пустое."
    if len(clean) % 4 != 0 or B64_RE.fullmatch(clean) is None:
        return None, MSG_NOT_B64
    return clean, None


def decode_b64(text):
    clean, error = normalize_b64(text)
    if error is not None:
        return None, error
    try:
        return base64.b64decode(clean, validate=True), None
    except (binascii.Error, ValueError):
        return None, MSG_NOT_B64


def validate_target_path(raw):
    text = raw.strip().strip('"').strip()
    if not text:
        return None, "Путь к файлу не указан."
    if text.endswith(("/", "\\")):
        return None, "В пути не указано имя файла."
    expanded = os.path.expandvars(os.path.expanduser(text))
    if os.path.isdir(expanded):
        return None, "Указанный путь является папкой, а не файлом."
    path = os.path.abspath(expanded)
    name = os.path.basename(path)
    if name in ("", ".", ".."):
        return None, "В пути не указано имя файла."
    if not (os.sep in text or "/" in text or "\\" in text or "." in name):
        return None, "Указанный текст не похож на путь к файлу."
    if name.endswith(".") or name.endswith(" "):
        return None, "Имя файла не должно заканчиваться точкой или пробелом."
    invalid = sorted({ch for ch in name if ch in INVALID_NAME_CHARS or ord(ch) < 32})
    if invalid:
        return None, "Имя файла содержит недопустимые символы: " + " ".join(invalid)
    parent = os.path.dirname(path)
    if not os.path.isdir(parent):
        return None, "Папка не найдена: " + parent
    return path, None


def make_text_area(parent, height=12):
    wrapper = ttk.Frame(parent)
    widget = tk.Text(wrapper, wrap="none", height=height, width=40, undo=True, font=("Consolas", 10))
    y_scroll = ttk.Scrollbar(wrapper, orient="vertical", command=widget.yview)
    x_scroll = ttk.Scrollbar(wrapper, orient="horizontal", command=widget.xview)
    widget.configure(yscrollcommand=y_scroll.set, xscrollcommand=x_scroll.set)
    widget.grid(row=0, column=0, sticky="nsew")
    y_scroll.grid(row=0, column=1, sticky="ns")
    x_scroll.grid(row=1, column=0, sticky="ew")
    wrapper.rowconfigure(0, weight=1)
    wrapper.columnconfigure(0, weight=1)
    return wrapper, widget


def add_context_menu(widget):
    menu = tk.Menu(widget, tearoff=0)
    menu.add_command(label="Вырезать", command=lambda: widget.event_generate("<<Cut>>"))
    menu.add_command(label="Копировать", command=lambda: widget.event_generate("<<Copy>>"))
    menu.add_command(label="Вставить", command=lambda: widget.event_generate("<<Paste>>"))

    def popup(event):
        widget.focus_set()
        try:
            menu.tk_popup(event.x_root, event.y_root)
        finally:
            menu.grab_release()

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


def guard_paste(widget, on_change=None):
    def handler(event):
        try:
            content = widget.clipboard_get()
        except tk.TclError:
            messagebox.showerror(
                "Ошибка вставки",
                "В буфере обмена нет текста.\nВставить можно только текст: "
                "изображения, файлы и прочие данные не поддерживаются.",
            )
            return "break"
        try:
            if widget.tag_ranges("sel"):
                widget.delete("sel.first", "sel.last")
            widget.insert("insert", content)
        except tk.TclError as exc:
            messagebox.showerror("Ошибка вставки", str(exc))
            return "break"
        if on_change is not None:
            widget.after(10, on_change)
        return "break"

    widget.bind("<Control-Key-v>", handler)
    widget.bind("<Control-Key-V>", handler)
    widget.bind("<<Paste>>", handler)


class Base64App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Base64 конвертер")
        self.geometry("1000x680")
        self.minsize(780, 560)
        self.t1_path = tk.StringVar()
        self.t1_result = tk.StringVar()
        self.t2_path = tk.StringVar()
        notebook = ttk.Notebook(self)
        notebook.pack(fill="both", expand=True, padx=8, pady=8)
        self.page1 = ttk.Frame(notebook, padding=10)
        self.page2 = ttk.Frame(notebook, padding=10)
        self.page3 = ttk.Frame(notebook, padding=10)
        self.page4 = ttk.Frame(notebook, padding=10)
        notebook.add(self.page1, text="File -> Base64")
        notebook.add(self.page2, text="Base64 -> File")
        notebook.add(self.page3, text="Текст <-> Base64")
        notebook.add(self.page4, text="Проверка Base64")
        self.build_page1()
        self.build_page2()
        self.build_page3()
        self.build_page4()

    def build_page1(self):
        page = self.page1
        page.columnconfigure(0, weight=1)
        ttk.Label(page, text="Поле №1 — файл для загрузки:").grid(row=0, column=0, sticky="w")
        row = ttk.Frame(page)
        row.grid(row=1, column=0, sticky="ew", pady=(4, 14))
        row.columnconfigure(0, weight=1)
        ttk.Entry(row, textvariable=self.t1_path).grid(row=0, column=0, sticky="ew")
        ttk.Button(row, text="Обзор...", command=self.pick_source_file).grid(row=0, column=1, padx=(6, 0))
        ttk.Label(page, text="Поле №2 — содержимое файла в формате Base64 (только для чтения):").grid(
            row=2, column=0, sticky="w"
        )
        self.t1_output = ttk.Entry(page, textvariable=self.t1_result, state="readonly")
        self.t1_output.grid(row=3, column=0, sticky="ew", pady=(4, 0))
        scroll = ttk.Scrollbar(page, orient="horizontal", command=self.t1_output.xview)
        self.t1_output.configure(xscrollcommand=scroll.set)
        scroll.grid(row=4, column=0, sticky="ew")
        ttk.Button(page, text="Конвертировать", command=self.file_to_base64).grid(row=5, column=0, pady=(18, 0))

    def build_page2(self):
        page = self.page2
        page.columnconfigure(0, weight=1)
        page.rowconfigure(1, weight=1)
        ttk.Label(page, text="Поле №1 — текст в формате Base64:").grid(row=0, column=0, sticky="w")
        wrapper, text = make_text_area(page, height=12)
        wrapper.grid(row=1, column=0, sticky="nsew", pady=(4, 12))
        self.t2_text = text
        add_context_menu(self.t2_text)
        guard_paste(self.t2_text, self.refresh_t2_status)
        ttk.Label(page, text="Поле №2 — путь к файлу для сохранения результата:").grid(row=2, column=0, sticky="w")
        row = ttk.Frame(page)
        row.grid(row=3, column=0, sticky="ew", pady=(4, 0))
        row.columnconfigure(0, weight=1)
        self.t2_path_entry = ttk.Entry(row, textvariable=self.t2_path)
        self.t2_path_entry.grid(row=0, column=0, sticky="ew")
        ttk.Button(row, text="Обзор...", command=self.pick_target_file).grid(row=0, column=1, padx=(6, 0))
        self.t2_status = ttk.Label(page, text="", foreground="#b00000", justify="left", wraplength=900)
        self.t2_status.grid(row=4, column=0, sticky="w", pady=(8, 0))
        ttk.Button(page, text="Конвертировать из Base64", command=self.base64_to_file).grid(
            row=5, column=0, pady=(10, 0)
        )
        self.t2_text.bind("<KeyRelease>", lambda event: self.refresh_t2_status())
        self.t2_text.bind("<FocusOut>", lambda event: self.refresh_t2_status())
        self.t2_path_entry.bind("<KeyRelease>", lambda event: self.refresh_t2_status())
        self.t2_path_entry.bind("<FocusOut>", lambda event: self.refresh_t2_status())
        self.refresh_t2_status()

    def build_page3(self):
        page = self.page3
        page.columnconfigure(0, weight=1)
        page.columnconfigure(1, weight=0)
        page.columnconfigure(2, weight=1)
        page.rowconfigure(0, weight=1)
        left_wrapper, left_text = make_text_area(page, height=16)
        left_wrapper.grid(row=0, column=0, sticky="nsew")
        right_wrapper, right_text = make_text_area(page, height=16)
        right_wrapper.grid(row=0, column=2, sticky="nsew")
        self.t3_left = left_text
        self.t3_right = right_text
        for widget in (self.t3_left, self.t3_right):
            add_context_menu(widget)
            guard_paste(widget)
        middle = tk.Frame(page, width=24, height=24, bd=0, highlightthickness=0)
        middle.grid(row=0, column=1, sticky="ns", padx=8)
        middle.grid_propagate(False)
        tk.Button(
            middle,
            text="→",
            font=("Segoe UI", 9),
            takefocus=0,
            command=self.text_to_base64,
        ).place(x=0, y=0, relwidth=1.0, relheight=0.5)
        tk.Button(
            middle,
            text="←",
            font=("Segoe UI", 9),
            takefocus=0,
            command=self.base64_to_text,
        ).place(x=0, rely=0.5, relwidth=1.0, relheight=0.5)

    def build_page4(self):
        page = self.page4
        page.columnconfigure(0, weight=1)
        page.rowconfigure(1, weight=1)
        ttk.Label(page, text="Вставьте текст в кодировке Base64:").grid(row=0, column=0, sticky="w")
        wrapper, text = make_text_area(page, height=16)
        wrapper.grid(row=1, column=0, sticky="nsew", pady=(4, 12))
        self.t4_text = text
        add_context_menu(self.t4_text)
        guard_paste(self.t4_text)
        ttk.Button(page, text="Проверить Base64", command=self.check_base64).grid(row=2, column=0)

    def pick_source_file(self):
        chosen = filedialog.askopenfilename(title="Выберите файл для загрузки", filetypes=[("Все файлы", "*.*")])
        if chosen:
            self.t1_path.set(chosen)

    def pick_target_file(self):
        chosen = filedialog.asksaveasfilename(title="Куда сохранить файл", filetypes=[("Все файлы", "*.*")])
        if chosen:
            self.t2_path.set(chosen)
        self.refresh_t2_status()

    def file_to_base64(self):
        while True:
            raw = self.t1_path.get().strip().strip('"')
            if not raw:
                messagebox.showerror(
                    "Ошибка", "Файл не выбран. Укажите путь к файлу или выберите его кнопкой «Обзор...»."
                )
                chosen = filedialog.askopenfilename(title="Выберите файл для загрузки", filetypes=[("Все файлы", "*.*")])
                if not chosen:
                    return
                self.t1_path.set(chosen)
                continue
            path = os.path.abspath(os.path.expandvars(os.path.expanduser(raw)))
            if os.path.isdir(path):
                messagebox.showerror("Ошибка", "Указанный путь является папкой, а не файлом.")
                chosen = filedialog.askopenfilename(title="Выберите файл для загрузки", filetypes=[("Все файлы", "*.*")])
                if not chosen:
                    return
                self.t1_path.set(chosen)
                continue
            try:
                with open(path, "rb") as handle:
                    data = handle.read()
            except OSError as exc:
                messagebox.showerror("Ошибка", "Не удалось прочитать файл:\n" + path + "\n\n" + str(exc))
                if not messagebox.askyesno("Выбор файла", "Выбрать другой файл?"):
                    return
                chosen = filedialog.askopenfilename(title="Выберите файл для загрузки", filetypes=[("Все файлы", "*.*")])
                if not chosen:
                    return
                self.t1_path.set(chosen)
                continue
            self.t1_result.set(base64.b64encode(data).decode("ascii"))
            return

    def refresh_t2_status(self):
        problems = []
        raw_b64 = self.t2_text.get("1.0", "end-1c")
        if not strip_ws(raw_b64):
            problems.append("в поле №1 нет Base64-текста")
        elif normalize_b64(raw_b64)[1] is not None:
            problems.append("в поле №1 не корректный Base64-текст")
        path_text = self.t2_path.get().strip()
        if not path_text:
            problems.append("в поле №2 не указан путь к файлу")
        else:
            path_error = validate_target_path(path_text)[1]
            if path_error is not None:
                problems.append("поле №2 — " + path_error)
        if problems:
            self.t2_status.configure(text="Не готово: " + "; ".join(problems) + ".", foreground="#b00000")
        else:
            self.t2_status.configure(text="Готово к сохранению файла.", foreground="#006400")

    def base64_to_file(self):
        while True:
            path, path_error = validate_target_path(self.t2_path.get())
            if path_error is not None:
                messagebox.showerror(
                    "Ошибка",
                    path_error + "\n\nУкажите корректный путь к файлу, например: C:\\Папка\\Файл.bin",
                )
                self.t2_path_entry.focus_set()
                self.refresh_t2_status()
                if messagebox.askyesno("Выбор файла", "Выбрать другой файл для сохранения?"):
                    chosen = filedialog.asksaveasfilename(title="Куда сохранить файл", filetypes=[("Все файлы", "*.*")])
                    if not chosen:
                        return
                    self.t2_path.set(chosen)
                    continue
                return
            data, b64_error = decode_b64(self.t2_text.get("1.0", "end-1c"))
            if b64_error is not None:
                messagebox.showerror(
                    "Ошибка",
                    "Поле №1 не содержит корректный Base64-текст.\nВставьте Base64-текст и повторите попытку.",
                )
                self.t2_text.focus_set()
                self.refresh_t2_status()
                return
            if os.path.exists(path) and not messagebox.askyesno(
                "Подтверждение", "Файл уже существует:\n" + path + "\n\nПерезаписать?"
            ):
                return
            try:
                with open(path, "wb") as handle:
                    handle.write(data)
            except OSError as exc:
                messagebox.showerror("Ошибка", "Не удалось сохранить файл:\n" + path + "\n\n" + str(exc))
                if not messagebox.askyesno("Выбор файла", "Выбрать другой файл для сохранения?"):
                    return
                chosen = filedialog.asksaveasfilename(title="Куда сохранить файл", filetypes=[("Все файлы", "*.*")])
                if not chosen:
                    return
                self.t2_path.set(chosen)
                continue
            self.refresh_t2_status()
            messagebox.showinfo("Готово", "Файл сохранён:\n" + path)
            return

    def text_to_base64(self):
        source = self.t3_left.get("1.0", "end-1c")
        self.t3_right.delete("1.0", "end")
        if source == "":
            return
        self.t3_right.insert("1.0", base64.b64encode(source.encode("utf-8")).decode("ascii"))
        self.t3_right.see("1.0")

    def base64_to_text(self):
        clean = strip_ws(self.t3_right.get("1.0", "end-1c"))
        if not clean:
            self.t3_left.delete("1.0", "end")
            return
        data, error = decode_b64(clean)
        if error is not None:
            messagebox.showerror("Ошибка", MSG_B64_ERROR_SHORT)
            return
        try:
            text = data.decode("utf-8")
        except UnicodeDecodeError:
            messagebox.showerror("Ошибка", "Декодированные данные не являются корректным текстом UTF-8.")
            return
        self.t3_left.delete("1.0", "end")
        self.t3_left.insert("1.0", text)
        self.t3_left.see("1.0")

    def check_base64(self):
        clean = strip_ws(self.t4_text.get("1.0", "end-1c"))
        if not clean:
            messagebox.showerror("Проверка Base64", MSG_NOT_B64)
            return
        _, error = decode_b64(clean)
        if error is not None:
            messagebox.showerror("Проверка Base64", MSG_NOT_B64)
            return
        messagebox.showinfo("Проверка Base64", "Формат Base64 верен")


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


if __name__ == "__main__":
    main()