"""실행 중인 밀리 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


def load_hook():
    """훅 스크립트를 읽어 온다. 실패는 호출한 쪽에서 처리하게 예외를 그대로 올린다."""
    return 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
        # recv 타임아웃 예외를 나중에 구분해서 삼키기 위해 클래스를 붙잡아 둔다.
        self._timeout_exc = websocket.WebSocketTimeoutException
        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
        # recv 자체 타임아웃(짧게)을 반복하며 deadline까지 기다린다.
        # 이렇게 해야 소켓 타임아웃이 아니라 의도한 8초 TimeoutError로 끝난다.
        while True:
            remaining = deadline - time.monotonic()
            if remaining <= 0:
                raise TimeoutError('뷰어 응답 시간이 초과됐습니다.')
            self.ws.settimeout(min(1.0, max(0.1, remaining)))
            try:
                reply = json.loads(self.ws.recv())
            except self._timeout_exc:
                continue  # 아직 응답 전 — deadline 안이면 계속 기다린다.
            if reply.get('id') == self.seq:
                if 'error' in reply:
                    raise RuntimeError(reply['error'].get('message', str(reply['error'])))
                return reply.get('result', {})

    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, hook, 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, hook, hook_error=None):
        self.root = root
        self.hook = hook
        self.hook_error = hook_error
        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)
        # 훅 로딩이 실패했으면 앱은 뜨되 이유를 알리고 캡처는 막는다.
        if self.hook is None:
            self.status.set('훅 스크립트를 불러오지 못해 암호 확인을 사용할 수 없습니다.\n'
                            + (self.hook_error or '')
                            + '\n(millie_gui_hook.js 파일이 이 프로그램과 같은 폴더에 있어야 합니다.)')
            self.capture_button.configure(state='disabled')
        else:
            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 or self.hook is None:
            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):
        if self.hook is None:
            self.status.set('훅 스크립트를 불러오지 못해 암호 확인을 사용할 수 없습니다.')
            return
        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('뷰어에 연결하고 있습니다…')
        hook = self.hook
        self.work(lambda: capture(self.targets[index], hook,
                  lambda message: self.events.put(('status', message)), self.cancelled), 'capture')

    def poll(self):
        # 이벤트 처리 중 어떤 예외가 나도 폴링 재예약(finally)은 반드시 실행되게 한다.
        try:
            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 and self.hook) 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 화면을 유지합니다.')
        except Exception as error:
            # 개별 이벤트가 깨져도 앱 전체가 멈추지 않게 상태창에만 알린다.
            try:
                self.status.set('화면 갱신 중 오류가 발생했습니다: ' + str(error))
            except Exception:
                pass
        finally:
            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()
    try:
        hook = load_hook()
        hook_error = None
    except OSError as error:
        hook = None
        hook_error = str(error)
    App(root, hook, hook_error)
    root.mainloop()
