# -*- coding: utf-8 -*-
# yjm_clip_ocr.py
# pip install PySide6 pillow pytesseract
# -*- coding: utf-8 -*-
import os
import sys
import json
import hashlib
import traceback
import webbrowser
import faulthandler
from copy import deepcopy
from datetime import datetime
from io import BytesIO
from pathlib import Path
from urllib import request

from PIL import Image, ImageOps, UnidentifiedImageError
import pytesseract

from PySide6.QtCore import QObject, Signal, QThread, QBuffer, QIODevice, QUrl, Qt
from PySide6.QtGui import QAction, QDesktopServices, QCursor, QIcon
from PySide6.QtWidgets import (
    QApplication,
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QPushButton,
    QTextEdit,
    QLabel,
    QSystemTrayIcon,
    QMenu,
    QMessageBox,
    QStyle,
    QRadioButton,
    QButtonGroup,
)


faulthandler.enable()

APP_FILE_NAME = "yjm_clip_ocr.py"


def get_main_path():
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve()
    return Path(__file__).resolve()


MAIN_PATH = get_main_path()
CONFIG_PATH = MAIN_PATH.with_suffix(".json")
LOG_PATH = MAIN_PATH.with_suffix(".log")
ICON_PATH = MAIN_PATH.parent / "_itc0.ico"


def log(msg):
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {msg}"
    try:
        print(line, flush=True)
    except Exception:
        pass

    try:
        with open(LOG_PATH, "a", encoding="utf-8") as f:
            f.write(line + "\n")
    except Exception:
        pass


def excepthook(exc_type, exc_value, exc_tb):
    tb = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
    log("치명적 예외 발생")
    log(tb)
    try:
        QMessageBox.critical(None, "치명적 오류", tb)
    except Exception:
        pass


sys.excepthook = excepthook


def default_config():
    return {
        "app_name": "YJM Clip OCR",
        "lang": "kor+eng",
        "tesseract_cmd": "C:/Program Files/Tesseract-OCR/tesseract.exe",
        "tessdata_prefix": "",
        "notify": {
            "enabled": True,
            "title": "클립보드 이미지 감지",
            "message": "클릭하면 OCR 결과를 엽니다",
            "timeout_ms": 3000
        },
        "ocr": {
            "oem": 3,
            "psm": 6,
            "preprocess": {
                "grayscale": True,
                "autocontrast": True,
                "upscale": 2,
                "color_mode": "dark"
            }
        },
        "post": {
            "enabled": False,
            "url": "",
            "timeout_sec": 15,
            "headers": {
                "Content-Type": "application/json; charset=utf-8"
            },
            "payload_template": {
                "text": "{text}",
                "lang": "{lang}",
                "source": "clipboard_image",
                "app_name": "yjm_clip_ocr",
                "created_at": "{timestamp}"
            },
            "response_editor_url_field": "editor_url",
            "open_editor_after_post": True,
            "editor_open_url": ""
        },
        "ui": {
            "always_on_top_result": False,
            "result_window_width": 900,
            "result_window_height": 650
        },
        "debug": {
            "enabled": True,
            "watch_clipboard": True,
            "show_result_on_start": True,
            "show_startup_message": True
        }
    }


def deep_merge(base, override):
    if not isinstance(base, dict) or not isinstance(override, dict):
        return deepcopy(override)

    result = deepcopy(base)
    for key, value in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = deepcopy(value)
    return result


def save_json(path, data):
    path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")


def load_or_create_config(path):
    defaults = default_config()

    if not path.exists():
        save_json(path, defaults)
        log(f"설정 파일 생성: {path}")
        return defaults, True

    try:
        loaded = json.loads(path.read_text(encoding="utf-8"))
        merged = deep_merge(defaults, loaded)
        if merged != loaded:
            save_json(path, merged)
            log("설정 파일에 기본 키 병합 저장 완료")
        return merged, False
    except Exception:
        bad_path = path.with_suffix(".bad." + datetime.now().strftime("%Y%m%d_%H%M%S") + ".json")
        try:
            path.rename(bad_path)
            log(f"깨진 설정 파일 백업: {bad_path}")
        except Exception:
            pass
        save_json(path, defaults)
        log("기본 설정 파일 재생성 완료")
        return defaults, True


def apply_tesseract_settings(config):
    tesseract_cmd = str(config.get("tesseract_cmd", "")).strip()
    if tesseract_cmd:
        pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
        log(f"tesseract_cmd 설정: {tesseract_cmd}")
        log(f"tesseract_cmd 존재 여부: {Path(tesseract_cmd).exists()}")

    tessdata_prefix = str(config.get("tessdata_prefix", "")).strip()
    if tessdata_prefix:
        os.environ["TESSDATA_PREFIX"] = tessdata_prefix
        log(f"TESSDATA_PREFIX 설정: {tessdata_prefix}")


def qimage_to_png_bytes(qimage):
    buf = QBuffer()
    buf.open(QIODevice.WriteOnly)
    qimage.save(buf, "PNG")
    data = bytes(buf.data())
    buf.close()
    return data


def qimage_sha1(qimage):
    return hashlib.sha1(qimage_to_png_bytes(qimage)).hexdigest()


def qimage_to_pil(qimage):
    data = qimage_to_png_bytes(qimage)
    img = Image.open(BytesIO(data))
    return img.convert("RGB")


def preprocess_pil_image(img, config):
    pp = config.get("ocr", {}).get("preprocess", {})
    color_mode = str(pp.get("color_mode", "dark")).strip().lower() or "dark"

    log(f"전처리 전 크기: {img.width}x{img.height}")
    log(f"전처리 color_mode: {color_mode}")

    if pp.get("grayscale", True):
        img = ImageOps.grayscale(img)
        log("전처리: grayscale 적용")

    upscale = int(pp.get("upscale", 2) or 1)
    if upscale > 1:
        img = img.resize((img.width * upscale, img.height * upscale), Image.Resampling.LANCZOS)
        log(f"전처리: upscale x{upscale} 적용 -> {img.width}x{img.height}")

    if pp.get("autocontrast", True):
        img = ImageOps.autocontrast(img)
        log("전처리: autocontrast 적용")

    if color_mode == "dark":
        img = ImageOps.invert(img)
        log("전처리: dark mode 반전 적용")
    else:
        log("전처리: white mode 반전 없음")

    return img


def do_ocr_from_qimage(qimage, config):
    apply_tesseract_settings(config)

    log("OCR 시작")
    pil_img = qimage_to_pil(qimage)
    pil_img = preprocess_pil_image(pil_img, config)

    ocr_cfg = config.get("ocr", {})
    oem = int(ocr_cfg.get("oem", 3))
    psm = int(ocr_cfg.get("psm", 6))
    lang = str(config.get("lang", "kor+eng")).strip() or "kor+eng"

    tesseract_config = f"--oem {oem} --psm {psm}"
    log(f"OCR 옵션: lang={lang}, config={tesseract_config}")

    text = pytesseract.image_to_string(pil_img, lang=lang, config=tesseract_config)
    text = text.replace("\r\n", "\n").strip()

    log(f"OCR 완료: {len(text)} 글자")
    return {
        "ok": 1,
        "text": text,
        "lang": lang,
        "length": len(text)
    }


def render_template(obj, ctx):
    if isinstance(obj, str):
        result = obj
        for key, value in ctx.items():
            result = result.replace("{" + key + "}", str(value))
        return result

    if isinstance(obj, dict):
        return {key: render_template(value, ctx) for key, value in obj.items()}

    if isinstance(obj, list):
        return [render_template(value, ctx) for value in obj]

    return obj


def do_post_text(config, text):
    post_cfg = config.get("post", {})
    url = str(post_cfg.get("url", "")).strip()
    if not url:
        raise ValueError("post.url 이 비어 있습니다.")

    headers = deepcopy(post_cfg.get("headers", {}))
    timeout_sec = int(post_cfg.get("timeout_sec", 15) or 15)

    ctx = {
        "text": text,
        "lang": str(config.get("lang", "kor+eng")),
        "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    }
    payload_template = deepcopy(post_cfg.get("payload_template", {}))
    payload = render_template(payload_template, ctx)

    data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
    req = request.Request(url=url, data=data, headers=headers, method="POST")

    log(f"POST 요청 시작: {url}")
    log(f"POST payload 키: {list(payload.keys())}")

    with request.urlopen(req, timeout=timeout_sec) as resp:
        status = getattr(resp, "status", resp.getcode())
        body_bytes = resp.read()
        charset = None
        try:
            charset = resp.headers.get_content_charset()
        except Exception:
            charset = None
        body_text = body_bytes.decode(charset or "utf-8", errors="replace")
        content_type = str(resp.headers.get("Content-Type", ""))

    log(f"POST 응답 코드: {status}")
    log(f"POST 응답 content-type: {content_type}")
    log(f"POST 응답 본문: {body_text[:1000]}")

    body_json = None
    try:
        body_json = json.loads(body_text)
    except Exception:
        body_json = None

    editor_url = ""
    editor_field = str(post_cfg.get("response_editor_url_field", "editor_url")).strip() or "editor_url"
    if isinstance(body_json, dict):
        editor_url = str(body_json.get(editor_field, "") or "")

    return {
        "ok": 1,
        "status": status,
        "content_type": content_type,
        "body_text": body_text,
        "body_json": body_json,
        "editor_url": editor_url
    }


class WorkerThread(QThread):
    result_ready = Signal(object)
    error_ready = Signal(str)

    def __init__(self, fn, *args, **kwargs):
        super().__init__()
        self.fn = fn
        self.args = args
        self.kwargs = kwargs

    def run(self):
        try:
            result = self.fn(*self.args, **self.kwargs)
            self.result_ready.emit(result)
        except Exception:
            tb = traceback.format_exc()
            log("WorkerThread 예외 발생")
            log(tb)
            self.error_ready.emit(tb)


class ResultWindow(QWidget):
    post_requested = Signal(str)
    reocr_requested = Signal()
    open_editor_requested = Signal()
    reload_config_requested = Signal()
    open_config_requested = Signal()
    open_log_requested = Signal()
    read_clip_requested = Signal()
    color_mode_changed = Signal(str)

    def __init__(self, config_path, config):
        super().__init__()
        self.config_path = config_path
        self.current_config = config

        self.setWindowTitle("YJM Clip OCR")
        self.resize(
            int(config.get("ui", {}).get("result_window_width", 900)),
            int(config.get("ui", {}).get("result_window_height", 650))
        )

        if config.get("ui", {}).get("always_on_top_result", False):
            self.setWindowFlag(Qt.WindowStaysOnTopHint, True)

        root = QVBoxLayout(self)

        self.lbl_status = QLabel("준비")
        self.lbl_status.setTextInteractionFlags(Qt.TextSelectableByMouse)
        root.addWidget(self.lbl_status)

        mode_wrap = QHBoxLayout()
        mode_wrap.addWidget(QLabel("OCR 화면 모드:"))

        self.radio_dark = QRadioButton("다크")
        self.radio_white = QRadioButton("화이트")

        self.mode_group = QButtonGroup(self)
        self.mode_group.addButton(self.radio_dark)
        self.mode_group.addButton(self.radio_white)

        mode_wrap.addWidget(self.radio_dark)
        mode_wrap.addWidget(self.radio_white)
        mode_wrap.addStretch(1)
        root.addLayout(mode_wrap)

        self.editor = QTextEdit()
        self.editor.setAcceptRichText(False)
        root.addWidget(self.editor, 1)

        btns = QHBoxLayout()

        self.btn_read_clip = QPushButton("클립 읽기")
        self.btn_copy = QPushButton("복사")
        self.btn_reocr = QPushButton("다시 OCR")
        self.btn_post = QPushButton("POST")
        self.btn_open_editor = QPushButton("편집기 열기")
        self.btn_open_cfg = QPushButton("설정 파일")
        self.btn_reload_cfg = QPushButton("설정 다시 읽기")
        self.btn_open_log = QPushButton("로그 열기")
        self.btn_close = QPushButton("닫기")

        btns.addWidget(self.btn_read_clip)
        btns.addWidget(self.btn_copy)
        btns.addWidget(self.btn_reocr)
        btns.addWidget(self.btn_post)
        btns.addWidget(self.btn_open_editor)
        btns.addWidget(self.btn_open_cfg)
        btns.addWidget(self.btn_reload_cfg)
        btns.addWidget(self.btn_open_log)
        btns.addWidget(self.btn_close)
        root.addLayout(btns)

        self.btn_read_clip.clicked.connect(self.read_clip_requested.emit)
        self.btn_copy.clicked.connect(self.copy_text)
        self.btn_reocr.clicked.connect(self.reocr_requested.emit)
        self.btn_post.clicked.connect(self.emit_post_requested)
        self.btn_open_editor.clicked.connect(self.open_editor_requested.emit)
        self.btn_open_cfg.clicked.connect(self.open_config_requested.emit)
        self.btn_reload_cfg.clicked.connect(self.reload_config_requested.emit)
        self.btn_open_log.clicked.connect(self.open_log_requested.emit)
        self.btn_close.clicked.connect(self.hide)

        self.radio_dark.toggled.connect(self.on_mode_toggled)
        self.radio_white.toggled.connect(self.on_mode_toggled)

        initial_mode = str(
            config.get("ocr", {}).get("preprocess", {}).get("color_mode", "dark")
        ).strip().lower() or "dark"
        self.set_color_mode(initial_mode)

    def closeEvent(self, event):
        self.hide()
        event.ignore()

    def set_status(self, text):
        self.lbl_status.setText(text)
        log(f"상태: {text}")

    def set_text(self, text):
        self.editor.setPlainText(text)

    def get_text(self):
        return self.editor.toPlainText()

    def copy_text(self):
        QApplication.clipboard().setText(self.get_text())
        self.set_status("텍스트를 클립보드에 복사했습니다.")

    def emit_post_requested(self):
        self.post_requested.emit(self.get_text())

    def show_front(self):
        self.show()
        self.raise_()
        self.activateWindow()

    def set_color_mode(self, mode):
        mode = str(mode or "dark").strip().lower()
        self.radio_dark.blockSignals(True)
        self.radio_white.blockSignals(True)

        if mode == "white":
            self.radio_white.setChecked(True)
            self.radio_dark.setChecked(False)
        else:
            self.radio_dark.setChecked(True)
            self.radio_white.setChecked(False)

        self.radio_dark.blockSignals(False)
        self.radio_white.blockSignals(False)

    def on_mode_toggled(self):
        if self.radio_dark.isChecked():
            self.color_mode_changed.emit("dark")
        elif self.radio_white.isChecked():
            self.color_mode_changed.emit("white")


class TrayApp(QObject):
    def __init__(self, app):
        super().__init__()
        self.app = app
        self.app.setQuitOnLastWindowClosed(False)

        log(f"MAIN_PATH: {MAIN_PATH}")
        log(f"CONFIG_PATH: {CONFIG_PATH}")
        log(f"LOG_PATH: {LOG_PATH}")
        log(f"ICON_PATH: {ICON_PATH}")
        log(f"현재 작업 폴더: {Path.cwd()}")
        log(f"Python 실행 파일: {sys.executable}")
        log(f"Python 버전: {sys.version}")
        log(f"트레이 사용 가능 여부: {QSystemTrayIcon.isSystemTrayAvailable()}")
        try:
            log(f"트레이 메시지 지원 여부: {QSystemTrayIcon.supportsMessages()}")
        except Exception:
            pass

        self.config, self.config_created = load_or_create_config(CONFIG_PATH)
        apply_tesseract_settings(self.config)

        self.clipboard = QApplication.clipboard()
        self.last_image = None
        self.last_image_hash = ""
        self.last_text = ""
        self.last_editor_url = ""
        self.threads = []

        self.result_window = ResultWindow(CONFIG_PATH, self.config)
        self.result_window.post_requested.connect(self.post_text)
        self.result_window.reocr_requested.connect(self.reocr_last)
        self.result_window.open_editor_requested.connect(self.open_editor)
        self.result_window.reload_config_requested.connect(self.reload_config)
        self.result_window.open_config_requested.connect(self.open_config_file)
        self.result_window.open_log_requested.connect(self.open_log_file)
        self.result_window.read_clip_requested.connect(self.read_clipboard_now)
        self.result_window.color_mode_changed.connect(self.on_color_mode_changed)

        self.create_tray()

        if self.config.get("debug", {}).get("watch_clipboard", True):
            self.clipboard.dataChanged.connect(self.on_clipboard_changed)
            log("클립보드 dataChanged 연결 완료")

        if self.config_created:
            self.tray.showMessage(
                "설정 파일 생성됨",
                f"{CONFIG_PATH.name} 파일이 생성되었습니다. 필요하면 수정하세요.",
                QSystemTrayIcon.Information,
                3500
            )

        if self.config.get("debug", {}).get("show_startup_message", True):
            try:
                self.tray.showMessage(
                    "YJM Clip OCR 시작",
                    "트레이 아이콘 또는 결과창에서 동작을 확인하세요.",
                    QSystemTrayIcon.Information,
                    2500
                )
            except Exception:
                pass

        self.result_window.set_status("앱 시작 완료")
        if self.config.get("debug", {}).get("show_result_on_start", True):
            self.result_window.show_front()

    def save_config(self):
        save_json(CONFIG_PATH, self.config)
        log(f"설정 저장 완료: {CONFIG_PATH}")

    def create_tray(self):
        if ICON_PATH.exists():
            icon = QIcon(str(ICON_PATH))
            log("_itc0.ico 로 아이콘 설정")
        else:
            icon = self.app.style().standardIcon(QStyle.SP_FileDialogContentsView)
            log("_itc0.ico 없음 -> 기본 아이콘 사용")

        self.app.setWindowIcon(icon)

        self.tray = QSystemTrayIcon(icon, self.app)
        self.tray.setToolTip(self.config.get("app_name", "YJM Clip OCR"))

        menu = QMenu()

        self.act_read_clip = QAction("OCR 클립 읽기", self.tray)
        self.act_open_last = QAction("마지막 결과창 열기", self.tray)
        self.act_post_last = QAction("마지막 결과 POST", self.tray)
        self.act_open_editor = QAction("편집기 열기", self.tray)
        self.act_open_cfg = QAction("설정 파일 열기", self.tray)
        self.act_open_log = QAction("로그 파일 열기", self.tray)
        self.act_reload_cfg = QAction("설정 다시 읽기", self.tray)
        self.act_quit = QAction("종료", self.tray)

        self.act_read_clip.triggered.connect(self.read_clipboard_now)
        self.act_open_last.triggered.connect(self.show_last_result)
        self.act_post_last.triggered.connect(lambda: self.post_text(self.last_text))
        self.act_open_editor.triggered.connect(self.open_editor)
        self.act_open_cfg.triggered.connect(self.open_config_file)
        self.act_open_log.triggered.connect(self.open_log_file)
        self.act_reload_cfg.triggered.connect(self.reload_config)
        self.act_quit.triggered.connect(self.app.quit)

        menu.addAction(self.act_read_clip)
        menu.addAction(self.act_open_last)
        menu.addAction(self.act_post_last)
        menu.addAction(self.act_open_editor)
        menu.addSeparator()
        menu.addAction(self.act_open_cfg)
        menu.addAction(self.act_open_log)
        menu.addAction(self.act_reload_cfg)
        menu.addSeparator()
        menu.addAction(self.act_quit)

        self.tray.setContextMenu(menu)
        self.tray.activated.connect(self.on_tray_activated)
        self.tray.messageClicked.connect(self.on_tray_message_clicked)
        self.tray.show()

        log("트레이 아이콘 show() 호출 완료")
        self.update_action_states()

    def on_tray_message_clicked(self):
        log("트레이 메시지 클릭됨")
        self.result_window.show_front()

    def show_tray_menu(self):
        menu = self.tray.contextMenu()
        if menu is not None:
            log("트레이 메뉴 표시")
            menu.popup(QCursor.pos())
        else:
            log("트레이 메뉴 없음")

    def on_tray_activated(self, reason):
        reason_map = {
            QSystemTrayIcon.Unknown: "Unknown",
            QSystemTrayIcon.Context: "Context",
            QSystemTrayIcon.DoubleClick: "DoubleClick",
            QSystemTrayIcon.Trigger: "Trigger",
            QSystemTrayIcon.MiddleClick: "MiddleClick",
        }
        log(f"트레이 활성화: {reason_map.get(reason, str(reason))}")

        if reason in (QSystemTrayIcon.Trigger, QSystemTrayIcon.DoubleClick):
            self.show_tray_menu()

    def update_action_states(self):
        has_text = bool((self.last_text or "").strip())
        has_editor = bool((self.last_editor_url or "").strip()) or bool(
            str(self.config.get("post", {}).get("editor_open_url", "") or "").strip()
        )

        self.act_open_last.setEnabled(True)
        self.act_post_last.setEnabled(has_text)
        self.act_open_editor.setEnabled(has_editor)

        log(f"액션 상태 갱신: has_text={has_text}, has_editor={has_editor}")

    def open_config_file(self):
        log(f"설정 파일 열기: {CONFIG_PATH}")
        QDesktopServices.openUrl(QUrl.fromLocalFile(str(CONFIG_PATH)))

    def open_log_file(self):
        log(f"로그 파일 열기: {LOG_PATH}")
        QDesktopServices.openUrl(QUrl.fromLocalFile(str(LOG_PATH)))

    def reload_config(self):
        self.config, _ = load_or_create_config(CONFIG_PATH)
        apply_tesseract_settings(self.config)
        self.result_window.current_config = self.config

        mode = str(
            self.config.get("ocr", {}).get("preprocess", {}).get("color_mode", "dark")
        ).strip().lower() or "dark"
        self.result_window.set_color_mode(mode)

        self.result_window.set_status("설정을 다시 읽었습니다.")
        self.update_action_states()
        try:
            self.tray.showMessage(
                "설정 다시 읽기",
                f"{CONFIG_PATH.name} 내용을 다시 적용했습니다.",
                QSystemTrayIcon.Information,
                2000
            )
        except Exception:
            pass

    def on_color_mode_changed(self, mode):
        mode = str(mode or "dark").strip().lower()
        if mode not in ["dark", "white"]:
            mode = "dark"

        self.config.setdefault("ocr", {})
        self.config["ocr"].setdefault("preprocess", {})
        self.config["ocr"]["preprocess"]["color_mode"] = mode
        self.save_config()

        if mode == "dark":
            self.result_window.set_status("OCR 화면 모드가 다크로 저장되었습니다.")
        else:
            self.result_window.set_status("OCR 화면 모드가 화이트로 저장되었습니다.")

        log(f"color_mode 변경 저장: {mode}")

    def on_clipboard_changed(self):
        try:
            mime = self.clipboard.mimeData()
            if mime is None:
                log("클립보드 변경: mimeData 없음")
                return

            formats = []
            try:
                formats = list(mime.formats())
            except Exception:
                pass

            log(f"클립보드 변경 감지: formats={formats}")
            log(f"hasImage={mime.hasImage()}, hasUrls={mime.hasUrls()}, hasText={mime.hasText()}")

            if mime.hasImage():
                qimage = self.clipboard.image()
                if qimage is not None and not qimage.isNull():
                    log(f"클립보드 이미지 크기: {qimage.width()}x{qimage.height()}")
                    try:
                        img_hash = qimage_sha1(qimage)
                        log(f"클립보드 이미지 sha1: {img_hash}")
                    except Exception:
                        pass

            if mime.hasUrls():
                try:
                    urls = [u.toLocalFile() or u.toString() for u in mime.urls()]
                    log(f"클립보드 URL 목록: {urls}")
                except Exception:
                    pass

            if mime.hasText():
                try:
                    txt = self.clipboard.text()
                    log(f"클립보드 텍스트 앞부분: {txt[:200]}")
                except Exception:
                    pass

        except Exception:
            log("on_clipboard_changed 예외")
            log(traceback.format_exc())

    def get_clipboard_qimage(self):
        mime = self.clipboard.mimeData()
        if mime is None:
            log("클립보드 읽기: mimeData 없음")
            return None, ""

        log("클립보드 읽기 시작")
        try:
            formats = list(mime.formats())
        except Exception:
            formats = []
        log(f"클립보드 formats={formats}")
        log(f"hasImage={mime.hasImage()}, hasUrls={mime.hasUrls()}, hasText={mime.hasText()}")

        if mime.hasImage():
            qimage = self.clipboard.image()
            if qimage is not None and not qimage.isNull():
                log(f"실제 이미지 데이터 읽음: {qimage.width()}x{qimage.height()}")
                return qimage, "image"
            log("mime.hasImage() 는 true 인데 qimage 가 null")

        if mime.hasUrls():
            for url in mime.urls():
                try:
                    local_path = url.toLocalFile()
                    if not local_path:
                        continue

                    ext = Path(local_path).suffix.lower()
                    log(f"URL 파일 후보: {local_path} / ext={ext}")

                    if ext not in [".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp", ".tif", ".tiff"]:
                        continue

                    pil = Image.open(local_path).convert("RGB")
                    data = BytesIO()
                    pil.save(data, format="PNG")

                    from PySide6.QtGui import QImage
                    qimage = QImage.fromData(data.getvalue(), "PNG")
                    if qimage is not None and not qimage.isNull():
                        log(f"파일 경로에서 이미지 로드 성공: {local_path}")
                        return qimage, "file"

                except (UnidentifiedImageError, OSError) as exc:
                    log(f"이미지 파일 해석 실패: {exc}")
                    continue
                except Exception:
                    log("파일 URL 처리 예외")
                    log(traceback.format_exc())
                    continue

        log("클립보드에서 이미지 없음")
        return None, ""

    def read_clipboard_now(self):
        log("메뉴/버튼: OCR 클립 읽기 실행")
        try:
            qimage, src_type = self.get_clipboard_qimage()
            if qimage is None:
                self.result_window.show_front()
                self.result_window.set_status("클립보드 이미지가 없습니다.")
                try:
                    self.tray.showMessage(
                        "클립보드 이미지 없음",
                        "현재 클립보드에 읽을 이미지가 없습니다.",
                        QSystemTrayIcon.Information,
                        2000
                    )
                except Exception:
                    pass
                return

            self.last_image = qimage
            self.last_image_hash = qimage_sha1(qimage)
            log(f"마지막 이미지 hash 저장: {self.last_image_hash}")

            if src_type == "file":
                self.result_window.set_status("클립보드 이미지 파일을 읽었습니다. OCR 실행 중...")
            else:
                self.result_window.set_status("클립보드 이미지를 읽었습니다. OCR 실행 중...")

            self.run_ocr(qimage)

        except Exception as exc:
            self.result_window.show_front()
            self.result_window.set_status(f"클립보드 읽기 오류: {exc}")
            log("read_clipboard_now 예외")
            log(traceback.format_exc())

    def show_last_result(self):
        log("메뉴: 마지막 결과창 열기")
        if not self.last_text:
            self.result_window.set_status("아직 OCR 결과가 없습니다.")
        self.result_window.show_front()

    def run_thread(self, fn, ok_callback, *args, **kwargs):
        thread = WorkerThread(fn, *args, **kwargs)
        self.threads.append(thread)
        log(f"스레드 시작: 현재 활성 스레드 수={len(self.threads)}")

        def cleanup():
            try:
                self.threads.remove(thread)
            except ValueError:
                pass
            log(f"스레드 종료: 현재 활성 스레드 수={len(self.threads)}")

        thread.result_ready.connect(ok_callback)
        thread.error_ready.connect(self.on_worker_error)
        thread.finished.connect(cleanup)
        thread.start()

    def run_ocr(self, qimage):
        log("run_ocr 호출")
        self.result_window.show_front()
        self.result_window.set_status("OCR 실행 중...")
        self.result_window.set_text("")
        self.run_thread(do_ocr_from_qimage, self.on_ocr_done, qimage, self.config)

    def on_ocr_done(self, result):
        self.last_text = result.get("text", "") or ""
        self.result_window.set_text(self.last_text)
        self.result_window.set_status(f"OCR 완료 / {result.get('length', 0)} 글자")
        self.update_action_states()
        log("OCR 결과 처리 완료")

    def on_worker_error(self, err_text):
        self.result_window.show_front()
        self.result_window.set_status("오류 발생")
        log("WorkerThread 오류 수신")
        log(err_text)
        QMessageBox.critical(self.result_window, "오류", err_text)

    def reocr_last(self):
        log("메뉴/버튼: 다시 OCR")
        if self.last_image is None:
            self.result_window.set_status("다시 OCR 할 마지막 이미지가 없습니다.")
            return
        self.run_ocr(self.last_image)

    def post_text(self, text):
        log("메뉴/버튼: POST 실행")
        text = str(text or "")
        if not text.strip():
            self.result_window.set_status("POST 할 텍스트가 없습니다.")
            return

        post_cfg = self.config.get("post", {})
        if not post_cfg.get("enabled", False):
            self.result_window.set_status("post.enabled 가 false 입니다. JSON 설정에서 true 로 바꾸세요.")
            return

        if not str(post_cfg.get("url", "")).strip():
            self.result_window.set_status("post.url 이 비어 있습니다.")
            return

        self.result_window.set_status("POST 전송 중...")
        self.run_thread(do_post_text, self.on_post_done, self.config, text)

    def on_post_done(self, result):
        status = result.get("status", "")
        self.last_editor_url = str(result.get("editor_url", "") or "")

        self.result_window.set_status(f"POST 완료 / HTTP {status}")
        self.update_action_states()

        if self.last_editor_url:
            log(f"editor_url 수신: {self.last_editor_url}")
            try:
                self.tray.showMessage(
                    "POST 완료",
                    "편집기 URL을 받았습니다.",
                    QSystemTrayIcon.Information,
                    2000
                )
            except Exception:
                pass

            if self.config.get("post", {}).get("open_editor_after_post", True):
                webbrowser.open(self.last_editor_url)
        else:
            log("editor_url 없음")
            try:
                self.tray.showMessage(
                    "POST 완료",
                    "응답은 받았지만 editor_url 은 없습니다.",
                    QSystemTrayIcon.Information,
                    2000
                )
            except Exception:
                pass

    def open_editor(self):
        log("메뉴/버튼: 편집기 열기")
        if self.last_editor_url:
            log(f"last_editor_url 열기: {self.last_editor_url}")
            webbrowser.open(self.last_editor_url)
            return

        fallback_url = str(self.config.get("post", {}).get("editor_open_url", "") or "").strip()
        if fallback_url:
            log(f"editor_open_url 열기: {fallback_url}")
            webbrowser.open(fallback_url)
            return

        self.result_window.set_status("열 편집기 URL이 없습니다. 응답 editor_url 또는 post.editor_open_url 을 확인하세요.")


def main():
    log("========== 앱 시작 ==========")

    app = QApplication(sys.argv)
    app.setApplicationName("YJM Clip OCR")
    app.setQuitOnLastWindowClosed(False)

    if not QSystemTrayIcon.isSystemTrayAvailable():
        msg = "이 시스템에서는 트레이 아이콘을 사용할 수 없습니다."
        log(msg)
        QMessageBox.critical(None, "오류", msg)
        sys.exit(1)

    tray_app = TrayApp(app)
    app._tray_app = tray_app

    log("이벤트 루프 진입")
    sys.exit(app.exec())


if __name__ == "__main__":
    main()