import base64
import math
import os
import queue
import struct
import threading
import time
import tkinter as tk
import zlib
from collections import Counter
from tkinter import filedialog, messagebox

PAGE_SIZE = 65536
IMAGE_SIZE = 256
SCALE = 2
VIEW_SIZE = IMAGE_SIZE * SCALE
THRESHOLD_LOW = 6.0
THRESHOLD_HIGH = 7.7
EMPTY_COLOR = "#4B2E83"
PENDING_COLOR = "#C0C0C0"
HIGHLIGHT_COLOR = "#FF00FF"
RED = "#D62728"
YELLOW = "#E0B000"
GREEN = "#2CA02C"
BAR_HEIGHT = 16
BATCH_PAGES = 16
PAGE_JUMP = 16
SLIDER_DELAY_MS = 150
HIGHLIGHT_MS = 2000
NAV_KEYS = ("Left", "Right", "Prior", "Next", "Home", "End")


def entropy_from_counts(counts, total):
    if total == 0:
        return None
    entropy = 0.0
    for count in counts:
        if count > 0:
            p = count / total
            entropy -= p * math.log2(p)
    return entropy


def calculate_entropy(data):
    if not data:
        return None
    return entropy_from_counts(Counter(data).values(), len(data))


def indicator_color(entropy):
    if entropy is None:
        return None
    if entropy > THRESHOLD_HIGH:
        return RED
    if entropy > THRESHOLD_LOW:
        return YELLOW
    return GREEN


def format_entropy(entropy):
    return "—" if entropy is None else f"{entropy:.3f}"


def format_int(value):
    return f"{value:,}".replace(",", " ")


def png_chunk(kind, payload):
    body = kind + payload
    return struct.pack(">I", len(payload)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)


def create_photo(data):
    pixel = bytes.fromhex(EMPTY_COLOR[1:])
    rgb = bytearray(pixel * PAGE_SIZE)
    n = len(data)
    if n:
        rgb[0:3 * n:3] = data
        rgb[1:3 * n:3] = data
        rgb[2:3 * n:3] = data
    row_size = IMAGE_SIZE * 3
    raw = bytearray()
    for y in range(IMAGE_SIZE):
        raw.append(0)
        raw.extend(rgb[y * row_size:(y + 1) * row_size])
    header = struct.pack(">IIBBBBB", IMAGE_SIZE, IMAGE_SIZE, 8, 2, 0, 0, 0)
    png = (
        b"\x89PNG\r\n\x1a\n"
        + png_chunk(b"IHDR", header)
        + png_chunk(b"IDAT", zlib.compress(bytes(raw), 1))
        + png_chunk(b"IEND", b"")
    )
    encoded = base64.b64encode(png).decode("ascii")
    image = tk.PhotoImage(data=encoded, format="png")
    return image.zoom(SCALE, SCALE)


def background_calculation(calc_id, path, total_pages, stop_event, out_queue):
    frequencies = Counter()
    total_bytes = 0
    batch = []
    batch_start = 0
    last_send = time.time()
    try:
        with open(path, "rb") as f:
            for page in range(total_pages):
                if stop_event.is_set():
                    return
                data = f.read(PAGE_SIZE)
                if not data:
                    break
                counts = Counter(data)
                frequencies.update(counts)
                total_bytes += len(data)
                batch.append(entropy_from_counts(counts.values(), len(data)))
                now = time.time()
                if len(batch) >= BATCH_PAGES or now - last_send >= 0.1:
                    percent = (page + 1) * 100 // total_pages
                    out_queue.put(("pages", calc_id, batch_start, batch, percent))
                    batch_start = page + 1
                    batch = []
                    last_send = now
    except OSError as e:
        out_queue.put(("error", calc_id, str(e)))
        return
    if stop_event.is_set():
        return
    if batch:
        out_queue.put(("pages", calc_id, batch_start, batch, 100))
    out_queue.put(("done", calc_id, entropy_from_counts(frequencies.values(), total_bytes)))


class EntropyViewer(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("Энтропийный просмотрщик")
        self.resizable(False, False)
        self.report_callback_exception = self.on_callback_exception
        self.protocol("WM_DELETE_WINDOW", self.on_close)

        self.file_path = None
        self.file_handle = None
        self.file_size = 0
        self.current_page = 0
        self.total_pages = 0
        self.current_page_bytes = None
        self.page_entropies = None

        self.calculation_id = 0
        self.stop_event = threading.Event()
        self.queue = queue.Queue()

        self.marker_line = None
        self.slider_after_id = None
        self.suppress_slider = False
        self.highlight_id = None
        self.highlight_after_id = None
        self.right_photo = None
        self.right_photo_id = None

        self.reference_bytes = self.generate_reference()
        self.reference_entropy = calculate_entropy(self.reference_bytes)
        self.ref_photo = create_photo(self.reference_bytes)

        self.create_ui()
        self.update_reference_display()
        self.update_navigation()
        self.bind_all("<KeyPress>", self.on_key)
        self.after(100, self.process_queue)

    @staticmethod
    def generate_reference():
        while True:
            data = os.urandom(PAGE_SIZE)
            entropy = calculate_entropy(data)
            if entropy is not None and entropy > THRESHOLD_HIGH:
                return data

    def create_ui(self):
        self.grid_columnconfigure(1, minsize=24)

        left_frame = tk.Frame(self)
        left_frame.grid(row=0, column=0, padx=(10, 0), pady=(10, 5))
        self.left_canvas = tk.Canvas(left_frame, width=VIEW_SIZE, height=VIEW_SIZE, highlightthickness=0)
        self.left_canvas.pack()

        right_frame = tk.Frame(self)
        right_frame.grid(row=0, column=2, padx=(0, 10), pady=(10, 5))
        self.right_canvas = tk.Canvas(right_frame, width=VIEW_SIZE, height=VIEW_SIZE, bg=EMPTY_COLOR, highlightthickness=0)
        self.right_canvas.pack()
        self.placeholder_id = self.right_canvas.create_text(
            VIEW_SIZE // 2, VIEW_SIZE // 2, text="Файл не загружен", fill="white"
        )

        left_info = tk.Frame(self)
        left_info.grid(row=1, column=0, padx=(10, 0), pady=(0, 10), sticky="nw")
        self.left_indicator, self.left_entropy_label = self.make_entropy_row(left_info, "Энтропия эталона: —")

        right_info = tk.Frame(self)
        right_info.grid(row=1, column=2, padx=(0, 10), pady=(0, 10), sticky="nw")
        self.page_indicator, self.page_entropy_label = self.make_entropy_row(right_info, "Энтропия страницы: —")
        self.file_indicator, self.file_entropy_label = self.make_entropy_row(right_info, "Энтропия файла: —")
        self.page_size_label = tk.Label(right_info, text="")
        self.page_size_label.pack(anchor="w", pady=(2, 0))

        control = tk.Frame(self)
        control.grid(row=2, column=0, columnspan=3, padx=10, pady=(0, 5), sticky="ew")

        top_row = tk.Frame(control)
        top_row.pack(fill=tk.X)
        tk.Button(top_row, text="Загрузить файл программы", command=self.load_file).pack(side=tk.LEFT, padx=(0, 10))
        self.offset_button = tk.Button(top_row, text="Перейти", command=self.go_to_offset)
        self.offset_button.pack(side=tk.RIGHT)
        self.offset_entry = tk.Entry(top_row, width=14)
        self.offset_entry.pack(side=tk.RIGHT, padx=5)
        self.offset_entry.bind("<Return>", lambda e: self.go_to_offset())
        tk.Label(top_row, text="Перейти к смещению:").pack(side=tk.RIGHT)
        self.file_info_label = tk.Label(top_row, text="", anchor=tk.W)
        self.file_info_label.pack(side=tk.LEFT, fill=tk.X, expand=True)

        nav = tk.Frame(control)
        nav.pack(pady=(5, 0))
        self.first_btn = tk.Button(nav, text="<<", command=lambda: self.go_to_page(0))
        self.first_btn.pack(side=tk.LEFT)
        self.prev_btn = tk.Button(nav, text="<", command=lambda: self.go_to_page(self.current_page - 1))
        self.prev_btn.pack(side=tk.LEFT, padx=(5, 0))
        self.page_label = tk.Label(nav, text="")
        self.page_label.pack(side=tk.LEFT, padx=10)
        self.range_label = tk.Label(nav, text="")
        self.range_label.pack(side=tk.LEFT, padx=10)
        self.next_btn = tk.Button(nav, text=">", command=lambda: self.go_to_page(self.current_page + 1))
        self.next_btn.pack(side=tk.LEFT)
        self.last_btn = tk.Button(nav, text=">>", command=lambda: self.go_to_page(self.total_pages - 1))
        self.last_btn.pack(side=tk.LEFT, padx=(5, 0))

        self.slider = tk.Scale(
            control, from_=1, to=1, resolution=1, orient=tk.HORIZONTAL,
            showvalue=False, takefocus=0, command=self.on_slider_change
        )
        self.slider.pack(fill=tk.X, pady=(5, 0))
        self.slider.bind("<ButtonRelease-1>", self.on_slider_release)

        self.bar_canvas = tk.Canvas(control, height=BAR_HEIGHT, highlightthickness=0)
        self.bar_canvas.pack(fill=tk.X, pady=(5, 0))
        self.bar_canvas.bind("<Button-1>", self.on_bar_click)
        self.bar_canvas.bind("<Configure>", lambda e: self.draw_entropy_bar())

        self.status_label = tk.Label(self, text="", anchor=tk.W)
        self.status_label.grid(row=3, column=0, columnspan=3, padx=10, pady=(0, 10), sticky="ew")

        self.left_canvas.bind("<Motion>", self.on_reference_motion)
        self.left_canvas.bind("<Leave>", self.clear_status)
        self.right_canvas.bind("<Motion>", self.on_file_motion)
        self.right_canvas.bind("<Leave>", self.clear_status)

        self.set_file_controls_state(False)

    @staticmethod
    def make_entropy_row(parent, text):
        row = tk.Frame(parent)
        row.pack(anchor="w", pady=(2, 0))
        indicator = tk.Canvas(row, width=12, height=12, highlightthickness=0)
        indicator.pack(side=tk.LEFT, padx=(0, 5))
        label = tk.Label(row, text=text)
        label.pack(side=tk.LEFT)
        return indicator, label

    @staticmethod
    def set_indicator(canvas, color):
        canvas.delete("all")
        if color:
            canvas.create_rectangle(0, 0, 12, 12, fill=color, outline="")

    def set_file_controls_state(self, enabled):
        state = tk.NORMAL if enabled else tk.DISABLED
        self.offset_entry.config(state=state)
        self.offset_button.config(state=state)
        self.slider.config(state=tk.NORMAL if enabled and self.total_pages > 1 else tk.DISABLED)

    def update_reference_display(self):
        self.left_canvas.delete("all")
        self.left_canvas.create_image(0, 0, anchor=tk.NW, image=self.ref_photo)
        self.left_entropy_label.config(text=f"Энтропия эталона: {format_entropy(self.reference_entropy)} бит/байт")
        self.set_indicator(self.left_indicator, indicator_color(self.reference_entropy))

    @staticmethod
    def file_error_message(error, path):
        if isinstance(error, FileNotFoundError):
            return f"Файл не найден: {path}"
        if isinstance(error, PermissionError):
            return f"Нет доступа к файлу: {path}"
        return f"Ошибка чтения файла: {error}"

    def load_file(self):
        path = filedialog.askopenfilename(
            title="Выбрать файл",
            filetypes=[
                ("Исполняемые файлы", "*.exe *.dll *.sys *.ocx *.scr *.cpl *.so *.elf *.bin"),
                ("Все файлы", "*.*"),
            ],
        )
        if not path:
            return
        try:
            handle = open(path, "rb")
        except OSError as e:
            messagebox.showerror("Ошибка", self.file_error_message(e, path))
            return
        try:
            size = os.fstat(handle.fileno()).st_size
            first_page = handle.read(PAGE_SIZE) if size > 0 else b""
        except OSError as e:
            handle.close()
            messagebox.showerror("Ошибка", self.file_error_message(e, path))
            return
        if size == 0 or not first_page:
            handle.close()
            messagebox.showerror("Ошибка", "Файл пустой, отображать нечего")
            return

        self.stop_background()
        self.close_file_handle()

        self.file_handle = handle
        self.file_path = path
        self.file_size = size
        self.total_pages = (size + PAGE_SIZE - 1) // PAGE_SIZE
        self.current_page = 0
        self.page_entropies = [None] * self.total_pages

        self.right_canvas.delete(self.placeholder_id)
        self.file_info_label.config(
            text=f"{os.path.basename(path)} — {format_int(size)} байт (0x{size:X})"
        )
        self.suppress_slider = True
        self.slider.config(to=max(1, self.total_pages))
        self.slider.set(1)
        self.suppress_slider = False
        self.set_file_controls_state(True)

        self.display_page(first_page)
        self.start_background_calculation()

    def stop_background(self):
        self.stop_event.set()
        self.calculation_id += 1

    def close_file_handle(self):
        if self.file_handle is not None:
            self.file_handle.close()
            self.file_handle = None

    def start_background_calculation(self):
        self.calculation_id += 1
        self.stop_event = threading.Event()
        self.file_entropy_label.config(text="Энтропия файла: расчёт… 0 %")
        self.set_indicator(self.file_indicator, None)
        self.draw_entropy_bar()
        thread = threading.Thread(
            target=background_calculation,
            args=(self.calculation_id, self.file_path, self.total_pages, self.stop_event, self.queue),
            daemon=True,
        )
        thread.start()

    def read_page(self, page):
        self.file_handle.seek(page * PAGE_SIZE)
        return self.file_handle.read(PAGE_SIZE)

    def display_page(self, data):
        self.current_page_bytes = data
        self.clear_highlight()
        if self.right_photo_id is not None:
            self.right_canvas.delete(self.right_photo_id)
        self.right_photo = create_photo(data)
        self.right_photo_id = self.right_canvas.create_image(0, 0, anchor=tk.NW, image=self.right_photo)

        page_entropy = calculate_entropy(data)
        self.page_entropy_label.config(text=f"Энтропия страницы: {format_entropy(page_entropy)} бит/байт")
        self.set_indicator(self.page_indicator, indicator_color(page_entropy))

        if len(data) < PAGE_SIZE:
            self.page_size_label.config(text=f"{format_int(len(data))} байт из {format_int(PAGE_SIZE)}")
        else:
            self.page_size_label.config(text="")

        self.suppress_slider = True
        self.slider.set(self.current_page + 1)
        self.suppress_slider = False
        self.update_navigation()
        self.draw_current_page_marker()

    def go_to_page(self, page):
        if self.total_pages == 0 or self.file_handle is None:
            return False
        page = max(0, min(page, self.total_pages - 1))
        try:
            data = self.read_page(page)
        except OSError as e:
            messagebox.showerror("Ошибка", self.file_error_message(e, self.file_path))
            return False
        if not data:
            messagebox.showerror("Ошибка", "Ошибка чтения файла: данные страницы недоступны")
            return False
        self.current_page = page
        self.display_page(data)
        return True

    def update_navigation(self):
        has_prev = self.total_pages > 1 and self.current_page > 0
        has_next = self.total_pages > 1 and self.current_page < self.total_pages - 1
        self.first_btn.config(state=tk.NORMAL if has_prev else tk.DISABLED)
        self.prev_btn.config(state=tk.NORMAL if has_prev else tk.DISABLED)
        self.next_btn.config(state=tk.NORMAL if has_next else tk.DISABLED)
        self.last_btn.config(state=tk.NORMAL if has_next else tk.DISABLED)
        self.update_page_labels(self.current_page)

    def update_page_labels(self, page):
        if self.total_pages == 0:
            self.page_label.config(text="Страница —")
            self.range_label.config(text="")
            return
        start = page * PAGE_SIZE
        end = min(start + PAGE_SIZE, self.file_size) - 1
        self.page_label.config(text=f"Страница {page + 1} из {self.total_pages}")
        self.range_label.config(text=f"0x{start:08X}–0x{end:08X}")

    def on_slider_change(self, value):
        if self.suppress_slider or self.total_pages == 0:
            return
        page = int(float(value)) - 1
        if 0 <= page < self.total_pages:
            self.update_page_labels(page)
        if self.slider_after_id is not None:
            self.after_cancel(self.slider_after_id)
        self.slider_after_id = self.after(SLIDER_DELAY_MS, self.load_page_from_slider)

    def on_slider_release(self, event):
        if self.total_pages == 0:
            return
        if self.slider_after_id is not None:
            self.after_cancel(self.slider_after_id)
            self.slider_after_id = None
        self.load_page_from_slider()

    def load_page_from_slider(self):
        self.slider_after_id = None
        page = int(float(self.slider.get())) - 1
        if page != self.current_page:
            self.go_to_page(page)
        else:
            self.update_page_labels(self.current_page)

    def on_key(self, event):
        ctrl = bool(event.state & 0x4)
        if ctrl and (event.keysym.lower() == "o" or event.keycode == 79):
            self.load_file()
            return "break"
        if event.keysym not in NAV_KEYS or self.total_pages == 0:
            return None
        if isinstance(event.widget, tk.Entry):
            return None
        targets = {
            "Left": self.current_page - 1,
            "Right": self.current_page + 1,
            "Prior": self.current_page - PAGE_JUMP,
            "Next": self.current_page + PAGE_JUMP,
            "Home": 0,
            "End": self.total_pages - 1,
        }
        target = max(0, min(targets[event.keysym], self.total_pages - 1))
        if target != self.current_page:
            self.go_to_page(target)
        return "break"

    def process_queue(self):
        while True:
            try:
                msg = self.queue.get_nowait()
            except queue.Empty:
                break
            if msg[1] != self.calculation_id:
                continue
            if msg[0] == "pages":
                _, _, start, entropies, percent = msg
                if self.page_entropies is not None:
                    self.page_entropies[start:start + len(entropies)] = entropies
                self.file_entropy_label.config(text=f"Энтропия файла: расчёт… {percent} %")
                self.draw_entropy_bar()
            elif msg[0] == "done":
                total_entropy = msg[2]
                self.file_entropy_label.config(text=f"Энтропия файла: {format_entropy(total_entropy)} бит/байт")
                self.set_indicator(self.file_indicator, indicator_color(total_entropy))
                self.draw_entropy_bar()
            elif msg[0] == "error":
                self.stop_background()
                self.file_entropy_label.config(text="Энтропия файла: —")
                messagebox.showerror("Ошибка", f"Ошибка чтения файла: {msg[2]}")
        self.after(100, self.process_queue)

    def draw_entropy_bar(self):
        self.bar_canvas.delete("all")
        self.marker_line = None
        if not self.page_entropies:
            return
        width = self.bar_canvas.winfo_width()
        if width <= 1:
            return
        total = len(self.page_entropies)
        colors = []
        for px in range(width):
            start = px * total // width
            end = max(start + 1, (px + 1) * total // width)
            known = [e for e in self.page_entropies[start:end] if e is not None]
            colors.append(indicator_color(max(known)) if known else PENDING_COLOR)
        run_start = 0
        for px in range(1, width + 1):
            if px == width or colors[px] != colors[run_start]:
                self.bar_canvas.create_rectangle(
                    run_start, 0, px, BAR_HEIGHT, fill=colors[run_start], outline=""
                )
                run_start = px
        self.draw_current_page_marker()

    def draw_current_page_marker(self):
        if self.marker_line is not None:
            self.bar_canvas.delete(self.marker_line)
            self.marker_line = None
        if not self.page_entropies:
            return
        width = self.bar_canvas.winfo_width()
        if width <= 1:
            return
        total = len(self.page_entropies)
        x = min(width - 1, int((self.current_page + 0.5) * width / total))
        self.marker_line = self.bar_canvas.create_line(x, 0, x, BAR_HEIGHT, fill="black", width=2)

    def on_bar_click(self, event):
        if not self.page_entropies:
            return
        width = self.bar_canvas.winfo_width()
        if width <= 1:
            return
        page = int(event.x * self.total_pages / width)
        self.go_to_page(max(0, min(page, self.total_pages - 1)))

    @staticmethod
    def parse_offset(text):
        value = text.strip().lower().replace(" ", "")
        if value.startswith("0x"):
            return int(value[2:], 16)
        if value.endswith("h"):
            return int(value[:-1], 16)
        return int(value, 10)

    def go_to_offset(self):
        if self.file_handle is None:
            return
        try:
            offset = self.parse_offset(self.offset_entry.get())
        except ValueError:
            messagebox.showerror("Ошибка", "Некорректное смещение")
            return
        if offset < 0:
            messagebox.showerror("Ошибка", "Некорректное смещение")
            return
        if offset >= self.file_size:
            messagebox.showerror("Ошибка", f"Смещение за пределами файла (размер 0x{self.file_size:X})")
            return
        page = offset // PAGE_SIZE
        if page != self.current_page and not self.go_to_page(page):
            return
        self.highlight_pixel(offset % PAGE_SIZE)

    def highlight_pixel(self, index):
        self.clear_highlight()
        x = index % IMAGE_SIZE
        y = index // IMAGE_SIZE
        self.highlight_id = self.right_canvas.create_rectangle(
            (x - 1) * SCALE, (y - 1) * SCALE, (x + 2) * SCALE, (y + 2) * SCALE,
            outline=HIGHLIGHT_COLOR, width=2
        )
        self.highlight_after_id = self.after(HIGHLIGHT_MS, self.clear_highlight)

    def clear_highlight(self):
        if self.highlight_after_id is not None:
            self.after_cancel(self.highlight_after_id)
            self.highlight_after_id = None
        if self.highlight_id is not None:
            self.right_canvas.delete(self.highlight_id)
            self.highlight_id = None

    @staticmethod
    def pixel_index(event):
        x = event.x // SCALE
        y = event.y // SCALE
        if 0 <= x < IMAGE_SIZE and 0 <= y < IMAGE_SIZE:
            return y * IMAGE_SIZE + x
        return None

    def on_reference_motion(self, event):
        idx = self.pixel_index(event)
        if idx is None:
            self.status_label.config(text="")
            return
        byte = self.reference_bytes[idx]
        self.status_label.config(text=f"Эталон: позиция 0x{idx:04X}, байт 0x{byte:02X} ({byte})")

    def on_file_motion(self, event):
        idx = self.pixel_index(event)
        if idx is None or self.current_page_bytes is None:
            self.status_label.config(text="")
            return
        offset = self.current_page * PAGE_SIZE + idx
        if idx < len(self.current_page_bytes):
            byte = self.current_page_bytes[idx]
            self.status_label.config(text=f"Смещение 0x{offset:08X}, байт 0x{byte:02X} ({byte})")
        else:
            self.status_label.config(text=f"Смещение 0x{offset:08X}: за пределами файла")

    def clear_status(self, event):
        self.status_label.config(text="")

    def on_callback_exception(self, exc_type, exc_value, exc_traceback):
        messagebox.showerror("Ошибка", f"{exc_type.__name__}: {exc_value}")

    def on_close(self):
        self.stop_background()
        self.close_file_handle()
        self.destroy()


if __name__ == "__main__":
    app = EntropyViewer()
    app.mainloop()