# yjm_clip_ocr.py
import os
import sys
import json
import hashlib
import traceback
import webbrowser
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,
)


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")
ICON_PATH = MAIN_PATH.parent / "_itc0.ico"


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": "\ud074\ub9bd\ubcf4\ub4dc \uc774\ubbf8\uc9c0 \uac10\uc9c0",
            "message": "\ud074\ub9ad\ud558\uba74 OCR \uacb0\uacfc\ub97c \uc5fd\ub2c8\ub2e4",
            "timeout_ms": 3000
        },
        "ocr": {
            "oem": 3,
            "psm": 6,
            "preprocess": {
                "grayscale": True,
                "autocontrast": True,
                "upscale": 2
            }
        },
        "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
        }
    }


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)
        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)
        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)
        except Exception:
            pass
        save_json(path, defaults)
        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

    tessdata_prefix = str(config.get("tessdata_prefix", "")).strip()
    if tessdata_prefix:
        os.environ["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", {})

    if pp.get("grayscale", True):
        img = ImageOps.grayscale(img)

    upscale = int(pp.get("upscale", 2) or 1)
    if upscale > 1:
        img = img.resize((img.width * upscale, img.height * upscale), Image.Resampling.LANCZOS)

    if pp.get("autocontrast", True):
        img = ImageOps.autocontrast(img)

    return img


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

    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}"
    text = pytesseract.image_to_string(pil_img, lang=lang, config=tesseract_config)
    text = text.replace("\r\n", "\n").strip()

    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 is empty.")

    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")

    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", ""))

    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:
            self.error_ready.emit(traceback.format_exc())


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

    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("\uc900\ube44")
        self.lbl_status.setTextInteractionFlags(Qt.TextSelectableByMouse)
        root.addWidget(self.lbl_status)

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

        btns = QHBoxLayout()

        self.btn_copy = QPushButton("\ubcf5\uc0ac")
        self.btn_reocr = QPushButton("\ub2e4\uc2dc OCR")
        self.btn_post = QPushButton("POST")
        self.btn_open_editor = QPushButton("\ud3b8\uc9d1\uae30 \uc5f4\uae30")
        self.btn_open_cfg = QPushButton("\uc124\uc815 \ud30c\uc77c")
        self.btn_reload_cfg = QPushButton("\uc124\uc815 \ub2e4\uc2dc \uc77d\uae30")
        self.btn_close = QPushButton("\ub2eb\uae30")

        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_close)
        root.addLayout(btns)

        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_close.clicked.connect(self.hide)

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

    def set_status(self, text):
        self.lbl_status.setText(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("\ud14d\uc2a4\ud2b8\ub97c \ud074\ub9bd\ubcf4\ub4dc\uc5d0 \ubcf5\uc0ac\ud588\uc2b5\ub2c8\ub2e4.")

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

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


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

        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.create_tray()

        if self.config_created:
            self.tray.showMessage(
                "\uc124\uc815 \ud30c\uc77c \uc0dd\uc131\ub428",
                f"{CONFIG_PATH.name} \ud30c\uc77c\uc774 \uc0dd\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud544\uc694\ud558\uba74 \uc218\uc815\ud558\uc138\uc694.",
                QSystemTrayIcon.Information,
                3500
            )

    def create_tray(self):
        if ICON_PATH.exists():
            icon = QIcon(str(ICON_PATH))
        else:
            icon = self.app.style().standardIcon(QStyle.SP_FileDialogContentsView)

        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 \ud074\ub9bd \uc77d\uae30", self.tray)
        self.act_open_last = QAction("\ub9c8\uc9c0\ub9c9 \uacb0\uacfc\ucc3d \uc5f4\uae30", self.tray)
        self.act_post_last = QAction("\ub9c8\uc9c0\ub9c9 \uacb0\uacfc POST", self.tray)
        self.act_open_editor = QAction("\ud3b8\uc9d1\uae30 \uc5f4\uae30", self.tray)
        self.act_open_cfg = QAction("\uc124\uc815 \ud30c\uc77c \uc5f4\uae30", self.tray)
        self.act_reload_cfg = QAction("\uc124\uc815 \ub2e4\uc2dc \uc77d\uae30", self.tray)
        self.act_quit = QAction("\uc885\ub8cc", 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_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_reload_cfg)
        menu.addSeparator()
        menu.addAction(self.act_quit)

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

        self.update_action_states()

    def show_tray_menu(self):
        menu = self.tray.contextMenu()
        if menu is not None:
            menu.popup(QCursor.pos())

    def on_tray_activated(self, 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(has_text)
        self.act_post_last.setEnabled(has_text)
        self.act_open_editor.setEnabled(has_editor)

    def open_config_file(self):
        QDesktopServices.openUrl(QUrl.fromLocalFile(str(CONFIG_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
        self.result_window.set_status("\uc124\uc815\uc744 \ub2e4\uc2dc \uc77d\uc5c8\uc2b5\ub2c8\ub2e4.")
        self.update_action_states()
        self.tray.showMessage(
            "\uc124\uc815 \ub2e4\uc2dc \uc77d\uae30",
            f"{CONFIG_PATH.name} \ub0b4\uc6a9\uc744 \ub2e4\uc2dc \uc801\uc6a9\ud588\uc2b5\ub2c8\ub2e4.",
            QSystemTrayIcon.Information,
            2000
        )

    def get_clipboard_qimage(self):
        mime = self.clipboard.mimeData()
        if mime is None:
            return None, ""

        if mime.hasImage():
            qimage = self.clipboard.image()
            if qimage is not None and not qimage.isNull():
                return qimage, "image"

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

                    ext = Path(local_path).suffix.lower()
                    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():
                        return qimage, "file"
                except (UnidentifiedImageError, OSError):
                    continue
                except Exception:
                    continue

        return None, ""

    def read_clipboard_now(self):
        try:
            qimage, src_type = self.get_clipboard_qimage()
            if qimage is None:
                self.result_window.set_status("\ud074\ub9bd\ubcf4\ub4dc \uc774\ubbf8\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.")
                self.tray.showMessage(
                    "\ud074\ub9bd\ubcf4\ub4dc \uc774\ubbf8\uc9c0 \uc5c6\uc74c",
                    "\ud604\uc7ac \ud074\ub9bd\ubcf4\ub4dc\uc5d0 \uc77d\uc744 \uc774\ubbf8\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.",
                    QSystemTrayIcon.Information,
                    2000
                )
                return

            self.last_image = qimage
            self.last_image_hash = qimage_sha1(qimage)

            if src_type == "file":
                self.result_window.set_status("\ud074\ub9bd\ubcf4\ub4dc \uc774\ubbf8\uc9c0 \ud30c\uc77c\uc744 \uc77d\uc5c8\uc2b5\ub2c8\ub2e4. OCR \uc2e4\ud589 \uc911...")
            else:
                self.result_window.set_status("\ud074\ub9bd\ubcf4\ub4dc \uc774\ubbf8\uc9c0\ub97c \uc77d\uc5c8\uc2b5\ub2c8\ub2e4. OCR \uc2e4\ud589 \uc911...")

            self.run_ocr(qimage)
        except Exception as exc:
            self.result_window.set_status(f"\ud074\ub9bd\ubcf4\ub4dc \uc77d\uae30 \uc624\ub958: {exc}")

    def show_last_result(self):
        if not self.last_text:
            self.result_window.set_status("\uc544\uc9c1 OCR \uacb0\uacfc\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.")
        self.result_window.show_front()

    def run_thread(self, fn, ok_callback, *args, **kwargs):
        thread = WorkerThread(fn, *args, **kwargs)
        self.threads.append(thread)

        def cleanup():
            try:
                self.threads.remove(thread)
            except ValueError:
                pass

        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):
        self.result_window.show_front()
        self.result_window.set_status("OCR \uc2e4\ud589 \uc911...")
        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 \uc644\ub8cc / {result.get('length', 0)} \uae00\uc790")
        self.update_action_states()

    def on_worker_error(self, err_text):
        self.result_window.show_front()
        self.result_window.set_status("\uc624\ub958 \ubc1c\uc0dd")
        QMessageBox.critical(self.result_window, "\uc624\ub958", err_text)

    def reocr_last(self):
        if self.last_image is None:
            self.result_window.set_status("\ub2e4\uc2dc OCR \ud560 \ub9c8\uc9c0\ub9c9 \uc774\ubbf8\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.")
            return
        self.run_ocr(self.last_image)

    def post_text(self, text):
        text = str(text or "")
        if not text.strip():
            self.result_window.set_status("POST \ud560 \ud14d\uc2a4\ud2b8\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.")
            return

        post_cfg = self.config.get("post", {})
        if not post_cfg.get("enabled", False):
            self.result_window.set_status("post.enabled \uac00 false \uc785\ub2c8\ub2e4. JSON \uc124\uc815\uc5d0\uc11c true \ub85c \ubc14\uafb8\uc138\uc694.")
            return

        if not str(post_cfg.get("url", "")).strip():
            self.result_window.set_status("post.url \uc774 \ube44\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.")
            return

        self.result_window.set_status("POST \uc804\uc1a1 \uc911...")
        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 \uc644\ub8cc / HTTP {status}")
        self.update_action_states()

        if self.last_editor_url:
            self.tray.showMessage(
                "POST \uc644\ub8cc",
                "\ud3b8\uc9d1\uae30 URL\uc744 \ubc1b\uc558\uc2b5\ub2c8\ub2e4.",
                QSystemTrayIcon.Information,
                2000
            )
            if self.config.get("post", {}).get("open_editor_after_post", True):
                webbrowser.open(self.last_editor_url)
        else:
            self.tray.showMessage(
                "POST \uc644\ub8cc",
                "\uc751\ub2f5\uc740 \ubc1b\uc558\uc9c0\ub9cc editor_url \uc740 \uc5c6\uc2b5\ub2c8\ub2e4.",
                QSystemTrayIcon.Information,
                2000
            )

    def open_editor(self):
        if 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:
            webbrowser.open(fallback_url)
            return

        self.result_window.set_status("\uc5f4 \ud3b8\uc9d1\uae30 URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc751\ub2f5 editor_url \ub610\ub294 post.editor_open_url \uc744 \ud655\uc778\ud558\uc138\uc694.")


def main():
    app = QApplication(sys.argv)

    if not QSystemTrayIcon.isSystemTrayAvailable():
        QMessageBox.critical(None, "\uc624\ub958", "\uc774 \uc2dc\uc2a4\ud15c\uc5d0\uc11c\ub294 \ud2b8\ub808\uc774 \uc544\uc774\ucf58\uc744 \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.")
        sys.exit(1)

    app.setApplicationName("YJM Clip OCR")

    tray_app = TrayApp(app)
    sys.exit(app.exec())


if __name__ == "__main__":
    main()