import csv
import ctypes
import html
import io
import os
import time
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

AUTO_ENCODING = "Авто"
AUTO_DELIMITER = "Авто"
ENCODING_CHOICES = (
    AUTO_ENCODING,
    "utf-8",
    "utf-8-sig",
    "cp1251",
    "cp866",
    "koi8-r",
    "cp1252",
    "latin-1",
    "utf-16",
)
DELIMITER_CHOICES = (
    AUTO_DELIMITER,
    "Запятая  ,",
    "Точка с запятой  ;",
    "Табуляция",
    "Вертикальная черта  |",
)
DELIMITER_VALUES = {
    "Запятая  ,": ",",
    "Точка с запятой  ;": ";",
    "Табуляция": "\t",
    "Вертикальная черта  |": "|",
}
DELIMITER_TITLES = {
    ",": "запятая",
    ";": "точка с запятой",
    "\t": "табуляция",
    "|": "вертикальная черта",
}
CF_UNICODETEXT = 13
GMEM_MOVEABLE = 0x0002
HTML_FORMAT = "HTML Format"


class ClipboardError(Exception):
    pass


class WindowsClipboard:
    def __init__(self):
        if not hasattr(ctypes, "windll"):
            raise ClipboardError("Буфер обмена доступен только в Windows.")
        self.user32 = ctypes.windll.user32
        self.kernel32 = ctypes.windll.kernel32
        self.user32.OpenClipboard.argtypes = [ctypes.c_void_p]
        self.user32.OpenClipboard.restype = ctypes.c_int
        self.user32.EmptyClipboard.argtypes = []
        self.user32.EmptyClipboard.restype = ctypes.c_int
        self.user32.CloseClipboard.argtypes = []
        self.user32.CloseClipboard.restype = ctypes.c_int
        self.user32.SetClipboardData.argtypes = [ctypes.c_uint, ctypes.c_void_p]
        self.user32.SetClipboardData.restype = ctypes.c_void_p
        self.user32.RegisterClipboardFormatW.argtypes = [ctypes.c_wchar_p]
        self.user32.RegisterClipboardFormatW.restype = ctypes.c_uint
        self.kernel32.GlobalAlloc.argtypes = [ctypes.c_uint, ctypes.c_size_t]
        self.kernel32.GlobalAlloc.restype = ctypes.c_void_p
        self.kernel32.GlobalLock.argtypes = [ctypes.c_void_p]
        self.kernel32.GlobalLock.restype = ctypes.c_void_p
        self.kernel32.GlobalUnlock.argtypes = [ctypes.c_void_p]
        self.kernel32.GlobalUnlock.restype = ctypes.c_int
        self.kernel32.GlobalFree.argtypes = [ctypes.c_void_p]
        self.kernel32.GlobalFree.restype = ctypes.c_void_p
        self.html_format = int(self.user32.RegisterClipboardFormatW(HTML_FORMAT))
        if not self.html_format:
            raise ClipboardError("Не удалось зарегистрировать формат HTML в буфере обмена.")

    def _open(self):
        for _ in range(20):
            if self.user32.OpenClipboard(None):
                return
            time.sleep(0.05)
        raise ClipboardError("Не удалось открыть буфер обмена: он используется другой программой.")

    def _put(self, fmt, blob):
        handle = self.kernel32.GlobalAlloc(GMEM_MOVEABLE, len(blob))
        if not handle:
            raise ClipboardError("Не удалось выделить память для буфера обмена.")
        pointer = self.kernel32.GlobalLock(handle)
        if not pointer:
            self.kernel32.GlobalFree(handle)
            raise ClipboardError("Не удалось получить доступ к памяти буфера обмена.")
        ctypes.memmove(pointer, blob, len(blob))
        self.kernel32.GlobalUnlock(handle)
        if not self.user32.SetClipboardData(fmt, handle):
            self.kernel32.GlobalFree(handle)
            raise ClipboardError("Не удалось поместить данные в буфер обмена.")

    def set_text(self, text):
        self._open()
        try:
            if not self.user32.EmptyClipboard():
                raise ClipboardError("Не удалось очистить буфер обмена.")
            self._put(CF_UNICODETEXT, text.encode("utf-16-le") + b"\x00\x00")
        finally:
            self.user32.CloseClipboard()

    def set_text_and_html(self, text, html_bytes):
        self._open()
        try:
            if not self.user32.EmptyClipboard():
                raise ClipboardError("Не удалось очистить буфер обмена.")
            self._put(CF_UNICODETEXT, text.encode("utf-16-le") + b"\x00\x00")
            self._put(self.html_format, html_bytes)
        finally:
            self.user32.CloseClipboard()


def detect_delimiter(text):
    lines = [line for line in text.splitlines()[:60] if line.strip()]
    if not lines:
        return ","
    try:
        return csv.Sniffer().sniff("\n".join(lines), delimiters=",;\t|").delimiter
    except csv.Error:
        pass
    best_delimiter = ","
    best_score = 0
    first_line = lines[0]
    for delimiter in (",", ";", "\t", "|"):
        score = first_line.count(delimiter)
        if score > best_score:
            best_delimiter = delimiter
            best_score = score
    return best_delimiter


def flatten_cell(value):
    text = value if isinstance(value, str) else str(value)
    text = text.replace("\r\n", " ")
    text = text.replace("\n", " ")
    text = text.replace("\r", " ")
    text = text.replace("\t", " ")
    return text


def rows_to_tsv(rows):
    return "\r\n".join("\t".join(flatten_cell(cell) for cell in row) for row in rows)


def build_html_fragment(rows, heading_rows):
    border = "1px solid #9aa4b0"
    heading_style = ("border:" + border + ";padding:3px 8px;background-color:#dbe5f1;"
                     "font-weight:bold;text-align:left;vertical-align:middle;")
    body_style = "border:" + border + ";padding:3px 8px;vertical-align:middle;"
    parts = ['<table border="1" cellspacing="0" cellpadding="3" '
             'style="border-collapse:collapse;font-family:Calibri,Segoe UI,Arial,sans-serif;font-size:11pt;">']
    for index, row in enumerate(rows):
        heading = index in heading_rows
        tag = "th" if heading else "td"
        style = heading_style if heading else body_style
        parts.append("<tr>")
        for cell in row:
            parts.append('<%s style="%s">%s</%s>' % (tag, style, html.escape(cell), tag))
        parts.append("</tr>")
    parts.append("</table>")
    return "".join(parts)


def build_html_clipboard(rows, heading_rows):
    fragment = build_html_fragment(rows, heading_rows)
    template = ("Version:1.0\r\nStartHTML:{:010d}\r\nEndHTML:{:010d}\r\n"
                "StartFragment:{:010d}\r\nEndFragment:{:010d}\r\n")
    prefix = "<html><body>\r\n<!--StartFragment-->"
    suffix = "<!--EndFragment-->\r\n</body></html>"
    header_size = len(template.format(0, 0, 0, 0).encode("utf-8"))
    prefix_bytes = prefix.encode("utf-8")
    fragment_bytes = fragment.encode("utf-8")
    suffix_bytes = suffix.encode("utf-8")
    start_fragment = header_size + len(prefix_bytes)
    end_fragment = start_fragment + len(fragment_bytes)
    end_html = end_fragment + len(suffix_bytes)
    header = template.format(header_size, end_html, start_fragment, end_fragment)
    return header.encode("utf-8") + prefix_bytes + fragment_bytes + suffix_bytes + b"\x00"


class CsvTableApp:
    def __init__(self, root):
        self.root = root
        self.clipboard = None
        self.file_path = None
        self.all_rows = []
        self.display_rows = []
        self.header_row = []
        self.row_by_item = {}
        self.used_encoding = ""
        self.used_delimiter = ","
        self.encoding_var = tk.StringVar(value=AUTO_ENCODING)
        self.delimiter_var = tk.StringVar(value=AUTO_DELIMITER)
        self.header_var = tk.BooleanVar(value=True)
        self.status_var = tk.StringVar(value="Файл не открыт.")
        self.selection_var = tk.StringVar(value="")
        self.root.title("CSV в буфер обмена")
        self.root.geometry("1100x680")
        self.root.minsize(680, 440)
        self.root.report_callback_exception = self.handle_callback_error
        self.configure_style()
        self.build_menu()
        self.build_toolbar()
        self.build_table()
        self.build_statusbar()
        self.bind_keys()

    def handle_callback_error(self, exc_type, exc_value, exc_traceback):
        messagebox.showerror("Необработанная ошибка", "%s: %s" % (exc_type.__name__, exc_value))

    def configure_style(self):
        style = ttk.Style(self.root)
        if "vista" in style.theme_names():
            style.theme_use("vista")
        style.configure("Treeview", rowheight=22)
        style.configure("Treeview.Heading", font=("Segoe UI", 9, "bold"))

    def build_menu(self):
        menubar = tk.Menu(self.root)
        file_menu = tk.Menu(menubar, tearoff=False)
        file_menu.add_command(label="Открыть CSV…", accelerator="Ctrl+O", command=self.open_file)
        file_menu.add_command(label="Закрыть файл", command=self.close_file)
        file_menu.add_separator()
        file_menu.add_command(label="Выход", accelerator="Alt+F4", command=self.root.destroy)
        menubar.add_cascade(label="Файл", menu=file_menu)

        edit_menu = tk.Menu(menubar, tearoff=False)
        edit_menu.add_command(label="Выделить все строки", accelerator="Ctrl+A", command=self.select_all)
        edit_menu.add_separator()
        edit_menu.add_command(label="Копировать как текст (CSV)", accelerator="Ctrl+Shift+C", command=self.copy_text)
        edit_menu.add_command(label="Копировать как таблицу без оформления (TSV)", accelerator="Ctrl+C",
                              command=self.copy_table)
        edit_menu.add_command(label="Копировать как таблицу с оформлением (HTML)", accelerator="Ctrl+H",
                              command=self.copy_html)
        menubar.add_cascade(label="Правка", menu=edit_menu)

        help_menu = tk.Menu(menubar, tearoff=False)
        help_menu.add_command(label="О программе", command=self.show_about)
        menubar.add_cascade(label="Справка", menu=help_menu)
        self.root.config(menu=menubar)

    def build_toolbar(self):
        outer = ttk.Frame(self.root, padding=(8, 6, 8, 2))
        outer.pack(side="top", fill="x")

        top = ttk.Frame(outer)
        top.pack(side="top", fill="x")
        ttk.Button(top, text="Открыть CSV…", command=self.open_file).pack(side="left")
        ttk.Label(top, text="Кодировка:").pack(side="left", padx=(14, 4))
        encoding_box = ttk.Combobox(top, textvariable=self.encoding_var, values=ENCODING_CHOICES,
                                    state="readonly", width=11)
        encoding_box.pack(side="left")
        encoding_box.bind("<<ComboboxSelected>>", self.on_settings_changed)
        ttk.Label(top, text="Разделитель:").pack(side="left", padx=(14, 4))
        delimiter_box = ttk.Combobox(top, textvariable=self.delimiter_var, values=DELIMITER_CHOICES,
                                     state="readonly", width=20)
        delimiter_box.pack(side="left")
        delimiter_box.bind("<<ComboboxSelected>>", self.on_settings_changed)
        ttk.Checkbutton(top, text="Первая строка — заголовок", variable=self.header_var,
                        command=self.refresh_view).pack(side="left", padx=(14, 0))

        bottom = ttk.Frame(outer)
        bottom.pack(side="top", fill="x", pady=(6, 0))
        ttk.Button(bottom, text="Копировать как текст (CSV)", command=self.copy_text).pack(side="left")
        ttk.Button(bottom, text="Копировать как таблицу (без оформления)",
                   command=self.copy_table).pack(side="left", padx=(6, 0))
        ttk.Button(bottom, text="Копировать как таблицу (с оформлением)",
                   command=self.copy_html).pack(side="left", padx=(6, 0))
        ttk.Label(bottom, text="Копируются выделенные строки, а если выделения нет — весь файл.",
                  foreground="#555555").pack(side="left", padx=(12, 0))

    def build_table(self):
        frame = ttk.Frame(self.root, padding=(8, 4))
        frame.pack(side="top", fill="both", expand=True)
        self.tree = ttk.Treeview(frame, show="headings", selectmode="extended")
        vertical = ttk.Scrollbar(frame, orient="vertical", command=self.tree.yview)
        horizontal = ttk.Scrollbar(frame, orient="horizontal", command=self.tree.xview)
        self.tree.configure(yscrollcommand=vertical.set, xscrollcommand=horizontal.set)
        self.tree.grid(row=0, column=0, sticky="nsew")
        vertical.grid(row=0, column=1, sticky="ns")
        horizontal.grid(row=1, column=0, sticky="ew")
        frame.rowconfigure(0, weight=1)
        frame.columnconfigure(0, weight=1)
        self.tree.bind("<<TreeviewSelect>>", self.on_selection)

    def build_statusbar(self):
        bar = ttk.Frame(self.root, padding=(8, 2, 8, 6))
        bar.pack(side="bottom", fill="x")
        ttk.Label(bar, textvariable=self.status_var, anchor="w").pack(side="left", fill="x", expand=True)
        ttk.Label(bar, textvariable=self.selection_var, anchor="e").pack(side="right")

    def bind_keys(self):
        self.root.bind("<Control-o>", self.shortcut_open)
        self.root.bind("<Control-c>", self.shortcut_copy_table)
        self.root.bind("<Control-Shift-C>", self.shortcut_copy_text)
        self.root.bind("<Control-h>", self.shortcut_copy_html)
        self.root.bind("<Control-a>", self.shortcut_select_all)

    def shortcut_open(self, event):
        self.open_file()
        return "break"

    def shortcut_copy_table(self, event):
        self.copy_table()
        return "break"

    def shortcut_copy_text(self, event):
        self.copy_text()
        return "break"

    def shortcut_copy_html(self, event):
        self.copy_html()
        return "break"

    def shortcut_select_all(self, event):
        self.select_all()
        return "break"

    def open_file(self):
        path = filedialog.askopenfilename(
            title="Открыть CSV-файл",
            filetypes=(("Файлы CSV и текстовые", "*.csv *.txt *.tsv"), ("Все файлы", "*.*")),
        )
        if not path:
            return
        self.load_file(path)

    def load_file(self, path):
        try:
            with open(path, "rb") as stream:
                raw = stream.read()
        except OSError as error:
            messagebox.showerror("Ошибка чтения", "Не удалось прочитать файл:\n%s\n\n%s" % (path, error))
            return
        text, encoding_name = self.decode_bytes(raw)
        delimiter = self.pick_delimiter(text)
        rows = self.parse_rows(text, delimiter)
        self.file_path = path
        self.used_encoding = encoding_name
        self.used_delimiter = delimiter
        self.all_rows = rows
        self.refresh_view()
        self.root.title("%s — CSV в буфер обмена" % os.path.basename(path))

    def decode_bytes(self, raw):
        preferred = self.encoding_var.get()
        if preferred != AUTO_ENCODING:
            try:
                return raw.decode(preferred), preferred
            except (UnicodeDecodeError, LookupError):
                pass
        if raw.startswith(b"\xef\xbb\xbf"):
            candidates = ("utf-8-sig",)
        elif raw.startswith((b"\xff\xfe", b"\xfe\xff")):
            candidates = ("utf-16",)
        else:
            candidates = ("utf-8", "cp1251", "cp866", "koi8-r", "cp1252")
        for name in candidates:
            try:
                return raw.decode(name), name
            except (UnicodeDecodeError, LookupError):
                continue
        return raw.decode("latin-1"), "latin-1"

    def pick_delimiter(self, text):
        choice = self.delimiter_var.get()
        if choice in DELIMITER_VALUES:
            return DELIMITER_VALUES[choice]
        return detect_delimiter(text)

    def parse_rows(self, text, delimiter):
        rows = []
        error = None
        reader = csv.reader(io.StringIO(text, newline=""), delimiter=delimiter)
        while True:
            try:
                row = next(reader)
            except StopIteration:
                break
            except csv.Error as problem:
                error = problem
                break
            rows.append(list(row))
        if error is not None:
            messagebox.showwarning("Предупреждение", "Часть данных не удалось разобрать:\n%s" % error)
        while rows and all(cell.strip() == "" for cell in rows[-1]):
            rows.pop()
        if not rows:
            return []
        width = max(len(row) for row in rows)
        return [row + [""] * (width - len(row)) for row in rows]

    def refresh_view(self):
        self.tree.delete(*self.tree.get_children())
        self.row_by_item.clear()
        self.selection_var.set("")
        if not self.all_rows:
            self.tree.configure(columns=())
            self.header_row = []
            self.display_rows = []
            self.update_status()
            return
        width = len(self.all_rows[0])
        with_header = bool(self.header_var.get())
        if with_header:
            self.header_row = list(self.all_rows[0])
            self.display_rows = [list(row) for row in self.all_rows[1:]]
        else:
            self.header_row = ["Столбец %d" % (index + 1) for index in range(width)]
            self.display_rows = [list(row) for row in self.all_rows]
        columns = ["column_%d" % index for index in range(width)]
        widths = [len(self.header_row[index]) for index in range(width)]
        for row in self.display_rows:
            for index, cell in enumerate(row):
                if index < width and len(cell) > widths[index]:
                    widths[index] = len(cell)
        self.tree.configure(columns=columns)
        for index, column in enumerate(columns):
            title = self.header_row[index].strip() or "Столбец %d" % (index + 1)
            self.tree.heading(column, text=title, anchor="w")
            self.tree.column(column, width=max(60, min(widths[index] * 8 + 24, 340)),
                             minwidth=40, anchor="w", stretch=False)
        for row in self.display_rows:
            item = self.tree.insert("", "end", values=row)
            self.row_by_item[item] = list(row)
        self.update_status()

    def update_status(self):
        if not self.file_path:
            self.status_var.set("Файл не открыт.")
            return
        rows_total = len(self.all_rows)
        columns_total = len(self.all_rows[0]) if self.all_rows else 0
        delimiter_title = DELIMITER_TITLES.get(self.used_delimiter, self.used_delimiter)
        self.status_var.set(
            "%s  •  строк: %d  •  столбцов: %d  •  кодировка: %s  •  разделитель: %s"
            % (os.path.basename(self.file_path), rows_total, columns_total,
               self.used_encoding, delimiter_title)
        )

    def on_selection(self, event=None):
        count = len(self.tree.selection())
        self.selection_var.set("выделено строк: %d" % count if count else "")

    def on_settings_changed(self, event=None):
        if self.file_path:
            self.load_file(self.file_path)

    def select_all(self):
        children = self.tree.get_children()
        if children:
            self.tree.selection_set(children)

    def close_file(self):
        self.file_path = None
        self.all_rows = []
        self.display_rows = []
        self.header_row = []
        self.row_by_item.clear()
        self.tree.delete(*self.tree.get_children())
        self.tree.configure(columns=())
        self.status_var.set("Файл не открыт.")
        self.selection_var.set("")
        self.root.title("CSV в буфер обмена")

    def rows_for_copy(self):
        selected = self.tree.selection()
        if selected:
            rows = [list(self.row_by_item[item]) for item in selected if item in self.row_by_item]
            if rows:
                return rows, False
        if not self.all_rows:
            return [], True
        return [list(row) for row in self.all_rows], True

    def get_clipboard(self):
        if self.clipboard is None:
            self.clipboard = WindowsClipboard()
        return self.clipboard

    def copy_text(self):
        rows, full = self.rows_for_copy()
        if not rows:
            messagebox.showinfo("Копирование", "Нет данных для копирования. Откройте CSV-файл.")
            return
        buffer = io.StringIO()
        writer = csv.writer(buffer, delimiter=self.used_delimiter, lineterminator="\r\n")
        try:
            writer.writerows(rows)
        except (csv.Error, TypeError) as error:
            messagebox.showerror("Ошибка", "Не удалось подготовить данные:\n%s" % error)
            return
        self.finish_copy(buffer.getvalue(), None, rows, full, "текст CSV")

    def copy_table(self):
        rows, full = self.rows_for_copy()
        if not rows:
            messagebox.showinfo("Копирование", "Нет данных для копирования. Откройте CSV-файл.")
            return
        self.finish_copy(rows_to_tsv(rows), None, rows, full, "таблица без оформления")

    def copy_html(self):
        rows, full = self.rows_for_copy()
        if not rows:
            messagebox.showinfo("Копирование", "Нет данных для копирования. Откройте CSV-файл.")
            return
        heading_rows = {0} if (full and self.header_var.get() and len(rows) > 1) else set()
        html_bytes = build_html_clipboard(rows, heading_rows)
        self.finish_copy(rows_to_tsv(rows), html_bytes, rows, full, "таблица с оформлением")

    def finish_copy(self, text, html_bytes, rows, full, kind):
        try:
            clipboard = self.get_clipboard()
            if html_bytes is None:
                clipboard.set_text(text)
            else:
                clipboard.set_text_and_html(text, html_bytes)
        except ClipboardError as error:
            messagebox.showerror("Ошибка буфера обмена", str(error))
            return
        except Exception as error:
            messagebox.showerror("Ошибка буфера обмена", "Не удалось скопировать данные:\n%s" % error)
            return
        source = "весь файл" if full else "выделенные строки"
        self.status_var.set("Скопировано в буфер обмена (%s, %s): строк %d, столбцов %d"
                            % (kind, source, len(rows), len(rows[0])))

    def show_about(self):
        messagebox.showinfo(
            "О программе",
            "Просмотр CSV-файлов и копирование таблиц в другие приложения.\n\n"
            "Режимы копирования:\n"
            "• как текст (CSV) — данные сохраняются в исходном формате;\n"
            "• как таблица без оформления — строки и столбцы (табуляция и перевод строки);\n"
            "• как таблица с оформлением — HTML-таблица с границами, заголовком и фоном."
        )


def main():
    root = tk.Tk()
    CsvTableApp(root)
    root.mainloop()


if __name__ == "__main__":
    main()