import datetime
import hashlib
import os
import queue
import shutil
import struct
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk

PHOTO_EXTS = {'.jpg', '.jpeg', '.jpe', '.jfif', '.png', '.gif', '.bmp', '.tif', '.tiff', '.webp', '.heic', '.heif'}
VIDEO_EXTS = {'.mp4', '.mov', '.avi', '.mkv', '.wmv', '.m4v', '.3gp', '.mpg', '.mpeg', '.webm', '.mts', '.m2ts', '.flv', '.ts'}
DUP_DIR_NAME = 'Дубли'
CHUNK_SIZE = 1048576
READ_LIMIT = 262144
MAX_PREVIEW = 1500
EXIF_TYPE_SIZE = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8}
DATE_FORMATS = ('%Y:%m:%d %H:%M:%S', '%Y:%m:%d %H:%M', '%Y:%m:%d')


def file_kind(name):
    ext = os.path.splitext(name)[1].lower()
    if ext in PHOTO_EXTS:
        return 'photo'
    if ext in VIDEO_EXTS:
        return 'video'
    return None


def parse_datetime_text(text):
    if not text:
        return None
    cleaned = text.strip()
    if not cleaned:
        return None
    if len(cleaned) >= 11 and cleaned[10:11] in ('T', 't'):
        cleaned = cleaned[:10] + ' ' + cleaned[11:]
    if cleaned.endswith('Z'):
        cleaned = cleaned[:-1]
    cleaned = cleaned.replace('-', ':').replace('/', ':').replace('.', ':')
    for fmt in DATE_FORMATS:
        try:
            return datetime.datetime.strptime(cleaned, fmt)
        except ValueError:
            continue
    return None


def read_ifd(data, fmt, offset):
    entries = {}
    if offset <= 0 or offset + 2 > len(data):
        return entries
    count = struct.unpack(fmt + 'H', data[offset:offset + 2])[0]
    if count > 1024:
        return entries
    for index in range(count):
        pos = offset + 2 + index * 12
        if pos + 12 > len(data):
            break
        tag, typ, cnt = struct.unpack(fmt + 'HHI', data[pos:pos + 8])
        size = EXIF_TYPE_SIZE.get(typ, 1) * cnt
        if size <= 4:
            value = data[pos + 8:pos + 8 + size]
        else:
            pointer = struct.unpack(fmt + 'I', data[pos + 8:pos + 12])[0]
            if pointer + size > len(data):
                continue
            value = data[pointer:pointer + size]
        entries[tag] = (typ, cnt, value)
    return entries


def entry_text(entries, tag):
    item = entries.get(tag)
    if item is None:
        return None
    typ, count, value = item
    if typ != 2 or not value:
        return None
    text = value.split(b'\x00')[0].decode('ascii', 'ignore').strip()
    return text or None


def tiff_datetime(data):
    if len(data) < 8:
        return None
    order = data[0:2]
    if order == b'II':
        fmt = '<'
    elif order == b'MM':
        fmt = '>'
    else:
        return None
    if struct.unpack(fmt + 'H', data[2:4])[0] != 42:
        return None
    ifd0 = read_ifd(data, fmt, struct.unpack(fmt + 'I', data[4:8])[0])
    exif_ifd = {}
    pointer = ifd0.get(0x8769)
    if pointer is not None and pointer[0] == 4 and len(pointer[2]) >= 4:
        exif_ifd = read_ifd(data, fmt, struct.unpack(fmt + 'I', pointer[2][0:4])[0])
    for entries, tag in ((exif_ifd, 0x9003), (exif_ifd, 0x9004), (ifd0, 0x0132)):
        moment = parse_datetime_text(entry_text(entries, tag))
        if moment is not None:
            return moment
    return None


def jpeg_datetime(path):
    with open(path, 'rb') as handle:
        head = handle.read(READ_LIMIT)
    if len(head) < 4 or head[0:2] != b'\xff\xd8':
        return None
    pos = 2
    limit = len(head)
    while pos + 4 <= limit:
        if head[pos] != 0xff:
            return None
        marker = head[pos + 1]
        if marker == 0xff:
            pos += 1
            continue
        if marker == 0xd8 or marker == 0x01 or 0xd0 <= marker <= 0xd7:
            pos += 2
            continue
        if marker == 0xda or marker == 0xd9:
            return None
        seg_len = struct.unpack('>H', head[pos + 2:pos + 4])[0]
        if seg_len < 2:
            return None
        if marker == 0xe1 and head[pos + 4:pos + 10] == b'Exif\x00\x00':
            return tiff_datetime(head[pos + 10:pos + 2 + seg_len])
        pos += 2 + seg_len
    return None


def png_datetime(path):
    with open(path, 'rb') as handle:
        head = handle.read(READ_LIMIT)
    if len(head) < 8 or head[0:8] != b'\x89PNG\r\n\x1a\n':
        return None
    pos = 8
    while pos + 12 <= len(head):
        length = struct.unpack('>I', head[pos:pos + 4])[0]
        chunk_type = head[pos + 4:pos + 8]
        if chunk_type == b'IDAT' or chunk_type == b'IEND':
            return None
        if chunk_type == b'tEXt':
            chunk = head[pos + 8:pos + 8 + length]
            key, sep, value = chunk.partition(b'\x00')
            if sep:
                key_text = key.decode('latin-1', 'ignore').lower()
                if 'date' in key_text or 'time' in key_text:
                    moment = parse_datetime_text(value.decode('latin-1', 'ignore'))
                    if moment is not None:
                        return moment
        if length > len(head):
            return None
        pos += 12 + length
    return None


def photo_datetime(path):
    ext = os.path.splitext(path)[1].lower()
    try:
        if ext in ('.jpg', '.jpeg', '.jpe', '.jfif'):
            return jpeg_datetime(path)
        if ext == '.png':
            return png_datetime(path)
    except (OSError, struct.error, ValueError, IndexError):
        return None
    return None


def file_digest(path):
    digest = hashlib.sha256()
    with open(path, 'rb') as handle:
        while True:
            chunk = handle.read(CHUNK_SIZE)
            if not chunk:
                break
            digest.update(chunk)
    return digest.hexdigest()


def free_name(path, taken):
    base, ext = os.path.splitext(path)
    candidate = path
    counter = 1
    while os.path.normcase(os.path.abspath(candidate)) in taken or os.path.exists(candidate):
        candidate = '%s_%d%s' % (base, counter, ext)
        counter += 1
    return candidate


def build_plan(root, emit, stop_event):
    files = []

    def on_error(error):
        emit('log', 'Ошибка доступа: %s' % error)

    for dirpath, dirnames, filenames in os.walk(root, onerror=on_error):
        dirnames[:] = sorted(name for name in dirnames if name.strip().lower() != DUP_DIR_NAME.lower())
        for name in filenames:
            if file_kind(name) is not None:
                files.append(os.path.join(dirpath, name))
    files.sort(key=lambda value: value.lower())
    total = len(files)
    emit('log', 'Найдено фото и видео: %d' % total)

    stats = {'photo': 0, 'video': 0, 'dup': 0, 'move': 0, 'skip': 0, 'error': 0}
    plan = []
    sizes = {}
    groups = {}
    broken = []
    for path in files:
        try:
            size = os.path.getsize(path)
        except OSError as error:
            broken.append((path, error))
            continue
        sizes[path] = size
        groups.setdefault(size, []).append(path)
    stats['error'] = len(broken)
    for path, error in broken:
        emit('log', 'Не удалось прочитать: %s (%s)' % (path, error))

    seen = {}
    taken = set()
    for index, path in enumerate(files, 1):
        if index % 200 == 0 or index == total:
            emit('progress', index, total)
            emit('log', 'Проверено %d из %d' % (index, total))
        if stop_event.is_set():
            emit('log', 'Анализ остановлен пользователем')
            break
        size = sizes.get(path)
        if size is None:
            continue
        kind = file_kind(os.path.basename(path))
        digest = ''
        if len(groups.get(size, ())) > 1:
            try:
                digest = file_digest(path)
            except OSError as error:
                stats['error'] += 1
                emit('log', 'Не удалось прочитать: %s (%s)' % (path, error))
                continue
        if kind == 'photo':
            stats['photo'] += 1
        else:
            stats['video'] += 1
        moment = photo_datetime(path) if kind == 'photo' else None
        if moment is None:
            try:
                moment = datetime.datetime.fromtimestamp(os.path.getmtime(path))
            except (OSError, OverflowError, ValueError):
                moment = datetime.datetime.now()
                emit('log', 'Дата не определена, взято текущее время: %s' % path)
        key = (size, digest)
        duplicate = key in seen
        if not duplicate:
            seen[key] = path
        ext = os.path.splitext(path)[1].lower()
        if duplicate:
            dest_dir = os.path.join(root, DUP_DIR_NAME)
            wanted = os.path.basename(path)
            action = 'Дубликат'
        else:
            dest_dir = os.path.join(root, '%04d' % moment.year, '%02d' % moment.month)
            wanted = moment.strftime('%Y-%m-%d_%H%M%S') + ext
            action = 'Переместить'
        target = os.path.join(dest_dir, wanted)
        if not duplicate and os.path.normcase(os.path.abspath(target)) == os.path.normcase(os.path.abspath(path)):
            final = target
            action = 'Без изменений'
        else:
            final = free_name(target, taken)
        taken.add(os.path.normcase(os.path.abspath(final)))
        if duplicate:
            stats['dup'] += 1
        elif action == 'Без изменений':
            stats['skip'] += 1
        else:
            stats['move'] += 1
        plan.append({
            'src': path,
            'moment': moment,
            'kind': kind,
            'duplicate': duplicate,
            'action': action,
            'dest_dir': dest_dir,
            'dest': final,
            'rel': os.path.relpath(final, root),
        })
    emit('plan', {'plan': plan, 'stats': stats, 'root': root})


def execute_plan(plan, root, emit, stop_event):
    items = [item for item in plan if item['action'] != 'Без изменений']
    total = len(items)
    moved = 0
    errors = 0
    stopped = False
    if total == 0:
        emit('done', {'moved': 0, 'errors': 0, 'stopped': False})
        return
    for index, item in enumerate(items, 1):
        if stop_event.is_set():
            stopped = True
            emit('log', 'Выполнение остановлено пользователем')
            break
        src = item['src']
        try:
            os.makedirs(item['dest_dir'], exist_ok=True)
            dest = item['dest']
            if os.path.exists(dest):
                dest = free_name(dest, set())
            shutil.move(src, dest)
            moved += 1
            emit('log', 'Готово: %s -> %s' % (os.path.basename(src), os.path.relpath(dest, root)))
        except (OSError, shutil.Error) as error:
            errors += 1
            emit('log', 'ОШИБКА: %s (%s)' % (src, error))
        emit('progress', index, total)
    emit('done', {'moved': moved, 'errors': errors, 'stopped': stopped})


class PhotoSorterApp:
    def __init__(self, root):
        self.root = root
        self.root.title('Разбор фотографий')
        self.root.geometry('1150x790')
        self.root.minsize(940, 640)
        self.root.configure(background='#f4f5f7')
        self.queue = queue.Queue()
        self.stop_event = threading.Event()
        self.worker = None
        self.plan = []
        self.plan_root = ''
        self.busy = False
        self._set_theme()
        self._build_ui()
        self.log_line('Программа готова. Выберите папку и нажмите «Анализ».')
        self.root.after(120, self._poll)

    def _set_theme(self):
        style = ttk.Style(self.root)
        for theme in ('vista', 'winnative', 'clam', 'default'):
            if theme in style.theme_names():
                try:
                    style.theme_use(theme)
                    break
                except tk.TclError:
                    continue
        style.configure('Big.TButton', font=('Segoe UI', 11), padding=(18, 10))
        style.configure('Head.TLabel', font=('Segoe UI', 11, 'bold'))
        style.configure('Normal.TLabel', font=('Segoe UI', 10))
        style.configure('Treeview', font=('Segoe UI', 9), rowheight=24)
        style.configure('Treeview.Heading', font=('Segoe UI', 9, 'bold'))

    def _build_ui(self):
        top = ttk.Frame(self.root, padding=(16, 14, 16, 6))
        top.pack(fill='x')
        ttk.Label(top, text='Папка с фотографиями и видео:', style='Head.TLabel').pack(anchor='w')
        pick_row = ttk.Frame(top)
        pick_row.pack(fill='x', pady=(8, 0))
        self.folder_var = tk.StringVar()
        self.folder_entry = ttk.Entry(pick_row, textvariable=self.folder_var, font=('Segoe UI', 11))
        self.folder_entry.pack(side='left', fill='x', expand=True, ipady=4)
        ttk.Button(pick_row, text='Выбрать папку', style='Big.TButton', command=self.choose_folder).pack(side='left', padx=(10, 0))

        actions = ttk.Frame(self.root, padding=(16, 8))
        actions.pack(fill='x')
        self.scan_button = ttk.Button(actions, text='Анализ', style='Big.TButton', command=self.start_scan)
        self.scan_button.pack(side='left')
        self.run_button = ttk.Button(actions, text='Выполнить', style='Big.TButton', command=self.start_execute, state='disabled')
        self.run_button.pack(side='left', padx=(10, 0))
        self.stop_button = ttk.Button(actions, text='Остановить', style='Big.TButton', command=self.request_stop, state='disabled')
        self.stop_button.pack(side='left', padx=(10, 0))

        self.summary_var = tk.StringVar(value='Выберите папку и нажмите «Анализ».')
        ttk.Label(self.root, textvariable=self.summary_var, style='Normal.TLabel', wraplength=1080, justify='left').pack(fill='x', padx=16, pady=(2, 8))

        table_frame = ttk.Frame(self.root, padding=(16, 0))
        table_frame.pack(fill='both', expand=True)
        columns = ('file', 'date', 'folder', 'name', 'action')
        self.tree = ttk.Treeview(table_frame, columns=columns, show='headings', height=12)
        headings = {'file': 'Файл', 'date': 'Дата съёмки', 'folder': 'Папка', 'name': 'Новое имя', 'action': 'Действие'}
        widths = {'file': 300, 'date': 150, 'folder': 120, 'name': 210, 'action': 130}
        for col in columns:
            self.tree.heading(col, text=headings[col])
            self.tree.column(col, width=widths[col], anchor='w', stretch=(col == 'file'))
        tree_vsb = ttk.Scrollbar(table_frame, orient='vertical', command=self.tree.yview)
        self.tree.configure(yscrollcommand=tree_vsb.set)
        self.tree.pack(side='left', fill='both', expand=True)
        tree_vsb.pack(side='right', fill='y')

        progress_frame = ttk.Frame(self.root, padding=(16, 10))
        progress_frame.pack(fill='x')
        self.progress = ttk.Progressbar(progress_frame, mode='determinate', maximum=100)
        self.progress.pack(side='left', fill='x', expand=True)
        self.percent_var = tk.StringVar(value='0 %')
        ttk.Label(progress_frame, textvariable=self.percent_var, width=8, anchor='e', style='Normal.TLabel').pack(side='left', padx=(10, 0))

        log_frame = ttk.Frame(self.root, padding=(16, 0, 16, 14))
        log_frame.pack(fill='x')
        ttk.Label(log_frame, text='Журнал:', style='Head.TLabel').pack(anchor='w')
        self.log = tk.Text(log_frame, height=9, wrap='word', font=('Consolas', 10), background='#ffffff', foreground='#1f2430', relief='solid', borderwidth=1)
        log_vsb = ttk.Scrollbar(log_frame, orient='vertical', command=self.log.yview)
        self.log.configure(yscrollcommand=log_vsb.set, state='disabled')
        self.log.pack(side='left', fill='both', expand=True, pady=(4, 0))
        log_vsb.pack(side='right', fill='y', pady=(4, 0))

    def log_line(self, text):
        self.log.configure(state='normal')
        self.log.insert('end', text + '\n')
        self.log.see('end')
        self.log.configure(state='disabled')

    def set_busy(self, busy):
        self.busy = busy
        if busy:
            self.scan_button.configure(state='disabled')
            self.run_button.configure(state='disabled')
            self.stop_button.configure(state='normal')
        else:
            self.scan_button.configure(state='normal')
            self.run_button.configure(state='normal' if self.plan else 'disabled')
            self.stop_button.configure(state='disabled')

    def choose_folder(self):
        initial = self.folder_var.get().strip() or os.path.expanduser('~')
        chosen = filedialog.askdirectory(initialdir=initial, title='Выберите папку с фотографиями')
        if chosen:
            self.folder_var.set(os.path.normpath(chosen))

    def emit(self, kind, payload):
        self.queue.put((kind, payload))

    def start_worker(self, func):
        def runner():
            try:
                func()
            except Exception as error:
                self.queue.put(('error', '%s: %s' % (type(error).__name__, error)))
        self.worker = threading.Thread(target=runner, daemon=True)
        self.worker.start()

    def start_scan(self):
        if self.busy:
            return
        folder = self.folder_var.get().strip()
        if not folder:
            messagebox.showerror('Разбор фотографий', 'Сначала выберите папку.')
            return
        if not os.path.isdir(folder):
            messagebox.showerror('Разбор фотографий', 'Папка не найдена:\n%s' % folder)
            return
        self.plan = []
        self.plan_root = folder
        self.tree.delete(*self.tree.get_children())
        self.progress.configure(value=0)
        self.percent_var.set('0 %')
        self.summary_var.set('Идёт анализ…')
        self.log_line('--- Анализ: %s ---' % folder)
        self.stop_event = threading.Event()
        self.set_busy(True)
        self.start_worker(lambda: build_plan(folder, self.emit, self.stop_event))

    def start_execute(self):
        if self.busy or not self.plan:
            return
        actionable = sum(1 for item in self.plan if item['action'] != 'Без изменений')
        duplicates = sum(1 for item in self.plan if item['duplicate'])
        if actionable == 0:
            messagebox.showinfo('Разбор фотографий', 'Все файлы уже на своих местах.')
            return
        question = ('К перемещению: %d файл(ов).\nВ папку «%s»: %d.\nФайлы не удаляются. Продолжить?' % (actionable, DUP_DIR_NAME, duplicates))
        if not messagebox.askyesno('Разбор фотографий', question):
            return
        self.progress.configure(value=0)
        self.percent_var.set('0 %')
        self.summary_var.set('Идёт перемещение…')
        self.log_line('--- Выполнение ---')
        self.stop_event = threading.Event()
        plan = list(self.plan)
        root_folder = self.plan_root
        self.set_busy(True)
        self.start_worker(lambda: execute_plan(plan, root_folder, self.emit, self.stop_event))

    def request_stop(self):
        if self.busy:
            self.stop_event.set()
            self.log_line('Запрошена остановка…')

    def _poll(self):
        try:
            while True:
                try:
                    kind, payload = self.queue.get_nowait()
                except queue.Empty:
                    break
                self.handle_message(kind, payload)
        except Exception as error:
            messagebox.showerror('Разбор фотографий', 'Ошибка интерфейса:\n%s' % error)
        self.root.after(120, self._poll)

    def handle_message(self, kind, payload):
        if kind == 'log':
            self.log_line(payload)
        elif kind == 'progress':
            done, total = payload
            percent = (done / total * 100.0) if total else 0.0
            self.progress.configure(value=percent)
            self.percent_var.set('%.0f %%' % percent)
        elif kind == 'plan':
            self.apply_plan(payload)
        elif kind == 'done':
            self.finish_execution(payload)
        elif kind == 'error':
            self.plan = []
            self.set_busy(False)
            self.summary_var.set('Произошла ошибка, подробности в журнале.')
            self.log_line('ОШИБКА: %s' % payload)
            messagebox.showerror('Разбор фотографий', 'Произошла ошибка:\n%s' % payload)
        else:
            raise ValueError('Неизвестное сообщение: %s' % kind)

    def apply_plan(self, payload):
        self.plan = payload['plan']
        self.plan_root = payload['root']
        stats = payload['stats']
        shown = 0
        for item in self.plan:
            if shown >= MAX_PREVIEW:
                break
            self.tree.insert('', 'end', values=(
                os.path.relpath(item['src'], self.plan_root),
                item['moment'].strftime('%d.%m.%Y %H:%M:%S'),
                os.path.relpath(item['dest_dir'], self.plan_root),
                os.path.basename(item['dest']),
                item['action']))
            shown += 1
        total = len(self.plan)
        summary = ('Фото: %d, видео: %d. К перемещению: %d. Дубликатов: %d. Уже на месте: %d. Ошибок: %d.'
                   % (stats['photo'], stats['video'], stats['move'], stats['dup'], stats['skip'], stats['error']))
        if shown < total:
            summary += ' В таблице показаны первые %d записей.' % shown
        self.summary_var.set(summary)
        self.progress.configure(value=100 if total else 0)
        self.percent_var.set('100 %' if total else '0 %')
        self.set_busy(False)
        self.log_line(summary)
        if total == 0:
            messagebox.showinfo('Разбор фотографий', 'В выбранной папке не найдено фото и видео.')

    def finish_execution(self, payload):
        moved = payload['moved']
        errors = payload['errors']
        stopped = payload['stopped']
        self.plan = []
        self.set_busy(False)
        if stopped:
            text = 'Остановлено. Перемещено: %d. Ошибок: %d.' % (moved, errors)
        else:
            text = 'Готово. Перемещено: %d. Ошибок: %d.' % (moved, errors)
        self.summary_var.set(text)
        self.log_line(text)
        if errors:
            messagebox.showwarning('Разбор фотографий', text + '\nПодробности в журнале.')
        else:
            messagebox.showinfo('Разбор фотографий', text)


def main():
    root = tk.Tk()
    app = PhotoSorterApp(root)
    root.mainloop()
    return app


if __name__ == '__main__':
    main()