"""실행 중인 밀리 PDF 연결 GUI. 앱 실행/종료 및 PDF 파일 변경 없음."""
import json
from pathlib import Path
import queue
import re
import subprocess
import threading
import time
import tkinter as tk
from tkinter import ttk
from urllib.parse import urlparse, parse_qs, unquote
import urllib.request

HOOK = Path(__file__).with_name('millie_gui_hook.js').read_text(encoding='utf-8')


def find_readers():
    command = (
        "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; "
        "Get-CimInstance Win32_Process | Where-Object { "
        "$_.Name -eq 'msedgewebview2.exe' -and "
        "$_.CommandLine -like '*millie-desktop*' } | "
        "Select-Object CommandLine | ConvertTo-Json -Compress"
    )
    result = subprocess.run(
        ['powershell.exe', '-NoProfile', '-Command', command],
        capture_output=True, encoding='utf-8-sig', errors='replace',
        creationflags=subprocess.CREATE_NO_WINDOW, timeout=20,
    )
    if result.returncode:
        raise RuntimeError('밀리 프로세스를 확인하지 못했습니다: ' + result.stderr.strip())
    rows = json.loads(result.stdout) if result.stdout.strip() else []
    if isinstance(rows, dict):
        rows = [rows]
    ports = set()
    for row in rows:
        match = re.search(r'--remote-debugging-port=(\d+)', row.get('CommandLine') or '')
        if match and int(match[1]):
            ports.add(int(match[1]))
    if not ports:
        raise RuntimeError(
            '밀리의 디버깅 연결을 찾지 못했습니다.\n'
            '디버깅이 활성화된 밀리에서 PDF 책을 연 뒤 다시 확인하세요.\n'
            '이 프로그램은 밀리를 재실행하지 않습니다.'
        )
    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
    readers, errors = [], []
    for port in ports:
        try:
            with opener.open(f'http://127.0.0.1:{port}/json/list', timeout=3) as response:
                targets = json.load(response)
            for target in targets:
                url = urlparse(target.get('url', ''))
                if (target.get('type') == 'page' and url.hostname in ('localhost', '127.0.0.1')
                        and parse_qs(url.query).get('bookSeq') and target.get('webSocketDebuggerUrl')):
                    readers.append(target)
        except (OSError, ValueError) as error:
            errors.append(str(error))
    if not readers:
        raise RuntimeError('열린 PDF 뷰어를 찾지 못했습니다. 밀리에서 PDF 책을 열어 주세요.'
                           + ('\n연결 오류: ' + '; '.join(errors) if errors else ''))
    return readers


class CDP:
    def __init__(self, url):
        try:
            import websocket
        except ImportError as error:
            raise RuntimeError('필요한 모듈을 설치하세요: python -m pip install websocket-client') from error
        self.ws = websocket.create_connection(
            url, timeout=5, suppress_origin=True, http_no_proxy=['localhost', '127.0.0.1'])
        self.seq = 0

    def call(self, method, params=None):
        self.seq += 1
        self.ws.send(json.dumps({'id': self.seq, 'method': method, 'params': params or {}}))
        deadline = time.monotonic() + 8
        while time.monotonic() < deadline:
            reply = json.loads(self.ws.recv())
            if reply.get('id') == self.seq:
                if 'error' in reply:
                    raise RuntimeError(reply['error'].get('message', str(reply['error'])))
                return reply.get('result', {})
        raise TimeoutError('뷰어 응답 시간이 초과됐습니다.')

    def evaluate(self, expression):
        reply = self.call('Runtime.evaluate', {'expression': expression, 'returnByValue': True})
        if reply.get('exceptionDetails'):
            raise RuntimeError('뷰어 상태를 읽지 못했습니다.')
        return reply.get('result', {}).get('value')

    def close(self):
        self.ws.close()


def capture(target, status=lambda message: None, cancelled=None):
    cancelled = cancelled or threading.Event()
    client = CDP(target['webSocketDebuggerUrl'])
    script_id = None
    try:
        state = client.evaluate('({url:location.href, page:window.pdf?.currentPage, '
                                'supported:typeof window.PSPDF === "function" || '
                                'typeof window.PDF === "function"})')
        if state['url'] != target['url']:
            raise RuntimeError('책이 변경됐습니다. 열린 책 목록을 다시 확인하세요.')
        if not state.get('supported'):
            raise RuntimeError('지원되는 PDF 뷰어가 아닙니다. PDF 책의 로딩이 끝난 뒤 다시 시도하세요.')
        cached = client.evaluate('window.__millieGuiResult || null')
        if cached and cached.get('hasPassword'):
            return cached
        client.call('Page.enable')
        script_id = client.call('Page.addScriptToEvaluateOnNewDocument', {'source': HOOK})['identifier']
        status('현재 PDF 화면을 새로고침하고 있습니다…')
        client.call('Page.reload')
        deadline = time.monotonic() + 35
        payload = None
        restored = False
        while time.monotonic() < deadline:
            if cancelled.is_set():
                raise RuntimeError('캡처를 중단했습니다.')
            data = client.evaluate('({capture:window.__millieGuiResult || null, '
                                   'ready:!!window.pdf?.instance, url:location.href})')
            if not data:
                time.sleep(0.2)
                continue
            if data['url'] != state['url']:
                raise RuntimeError('캡처 중 다른 책으로 이동했습니다. 다시 시도하세요.')
            payload = data.get('capture') or payload
            if data.get('ready'):
                page = state.get('page')
                if isinstance(page, int) and not restored:
                    client.evaluate(f'window.pdf.setPage({page})')
                    restored = True
                if payload:
                    if not payload.get('hasPassword'):
                        raise RuntimeError('이 PDF의 초기화 과정에서는 암호가 전달되지 않았습니다.')
                    return payload
            time.sleep(0.25)
        if payload and payload.get('hasPassword'):
            payload['warning'] = '암호는 캡처했지만 읽던 위치의 복원은 확인하지 못했습니다.'
            return payload
        raise RuntimeError('암호 전달을 감지하지 못했습니다. PDF가 정상적으로 열리는지 확인하세요.')
    finally:
        if script_id:
            try:
                client.call('Page.removeScriptToEvaluateOnNewDocument', {'identifier': script_id})
            except Exception:
                pass
        client.close()


class App:
    def __init__(self, root):
        self.root = root
        self.events = queue.Queue()
        self.cancelled = threading.Event()
        self.targets = []
        self.busy = False
        root.title('밀리 PDF 암호 확인')
        root.geometry('760x420')
        root.minsize(650, 390)
        root.option_add('*Font', ('맑은 고딕', 10))
        frame = ttk.Frame(root, padding=22)
        frame.pack(fill='both', expand=True)
        ttk.Label(frame, text='현재 열린 PDF 암호', font=('맑은 고딕', 17, 'bold')).pack(anchor='w')
        ttk.Label(frame, text='밀리에서 PDF 책을 연 뒤 선택하세요. 앱은 재실행하지 않습니다.').pack(anchor='w', pady=(8, 4))
        ttk.Label(frame, text='첫 캡처 시 PDF 화면만 새로고침하며 읽던 위치를 복원합니다.').pack(anchor='w')
        row = ttk.Frame(frame)
        row.pack(fill='x', pady=(18, 12))
        self.books = ttk.Combobox(row, state='readonly')
        self.books.pack(side='left', fill='x', expand=True)
        self.books.bind('<<ComboboxSelected>>', lambda event: self.clear_result())
        self.refresh_button = ttk.Button(row, text='열린 책 확인', command=self.refresh)
        self.refresh_button.pack(side='left', padx=(8, 0))
        self.capture_button = ttk.Button(frame, text='선택한 PDF 암호 확인', command=self.start_capture)
        self.capture_button.pack(anchor='w')
        self.filename = tk.StringVar(value='파일: —')
        ttk.Label(frame, textvariable=self.filename).pack(anchor='w', pady=(16, 5))
        self.password = tk.StringVar()
        ttk.Entry(frame, textvariable=self.password, state='readonly', font=('Consolas', 10)).pack(fill='x')
        self.copy_button = ttk.Button(frame, text='암호 복사', command=self.copy, state='disabled')
        self.copy_button.pack(anchor='e', pady=8)
        self.status = tk.StringVar(value='열린 책을 확인하고 있습니다…')
        ttk.Label(frame, textvariable=self.status, wraplength=690, justify='left').pack(anchor='w')
        root.protocol('WM_DELETE_WINDOW', self.close)
        root.after(100, self.poll)
        root.after(200, self.refresh)

    def clear_result(self):
        self.password.set('')
        self.filename.set('파일: —')
        self.copy_button.configure(state='disabled')

    def work(self, operation, kind):
        if self.busy:
            return
        self.busy = True
        self.refresh_button.configure(state='disabled')
        self.capture_button.configure(state='disabled')
        self.books.configure(state='disabled')
        def run():
            try:
                self.events.put((kind, operation()))
            except Exception as error:
                self.events.put(('error', str(error)))
            finally:
                self.events.put(('done', None))
        threading.Thread(target=run, daemon=True).start()

    def refresh(self):
        if self.busy:
            return
        self.clear_result()
        self.targets = []
        self.books.set('')
        self.books.configure(values=[])
        self.status.set('열린 책을 확인하고 있습니다…')
        self.work(find_readers, 'readers')

    def start_capture(self):
        index = self.books.current()
        if self.busy or not 0 <= index < len(self.targets):
            self.status.set('먼저 열린 PDF 책을 선택하세요.')
            return
        self.clear_result()
        self.status.set('뷰어에 연결하고 있습니다…')
        self.work(lambda: capture(self.targets[index],
                  lambda message: self.events.put(('status', message)), self.cancelled), 'capture')

    def poll(self):
        while not self.events.empty():
            kind, value = self.events.get_nowait()
            if kind == 'done':
                self.busy = False
                self.refresh_button.configure(state='normal')
                self.capture_button.configure(state='normal' if self.targets else 'disabled')
                self.books.configure(state='readonly')
            elif kind in ('error', 'status'):
                self.status.set(value)
            elif kind == 'readers':
                self.targets = value
                self.books.configure(values=[t.get('title') or t['url'] for t in value])
                self.books.current(0)
                self.status.set('암호를 확인할 PDF를 선택하고 버튼을 누르세요.')
            elif kind == 'capture':
                self.password.set(value['password'])
                self.filename.set('파일: ' + unquote(urlparse(value.get('url', '')).path.rsplit('/', 1)[-1]))
                self.copy_button.configure(state='normal')
                self.status.set(value.get('warning') or '암호를 캡처했습니다. 읽던 PDF 화면을 유지합니다.')
        self.root.after(100, self.poll)

    def copy(self):
        if self.password.get():
            self.root.clipboard_clear()
            self.root.clipboard_append(self.password.get())
            self.status.set('암호를 복사했습니다.')

    def close(self):
        self.cancelled.set()
        self.root.destroy()


if __name__ == '__main__':
    root = tk.Tk()
    App(root)
    root.mainloop()
