#!/usr/bin/env python3
import base64
import ctypes
import fcntl
import json
import os
import platform
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request
import uuid

BASE_URL = "https://mcko.kittieslabs.tech"
CLIENT_TOKEN = "mXo9D2J6spSUGH4tigKayPohRaovnUITEt7YaExgr2U"
CONNECT_TIMEOUT = 900
MAX_ATTEMPTS = 4
PLATFORM = platform.system().lower()
IS_DARWIN = PLATFORM == "darwin"
IS_LINUX = PLATFORM == "linux"
if IS_DARWIN:
    LOG_PATH = os.path.expanduser("~/Library/Logs/ScreenAnswer/client.log")
    APP_ROOT = os.path.expanduser("~/Library/Application Support/ScreenAnswer")
    APPLICATIONS_DIR = None
    AUTOSTART_DIR = None
else:
    STATE_HOME = os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state")
    DATA_HOME = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
    CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
    LOG_PATH = os.path.join(STATE_HOME, "screen-answer", "client.log")
    APP_ROOT = os.path.join(DATA_HOME, "screen-answer")
    APPLICATIONS_DIR = os.path.join(DATA_HOME, "applications")
    AUTOSTART_DIR = os.path.join(CONFIG_HOME, "autostart")
BIN_DIR = os.path.join(APP_ROOT, "bin")
DAEMON_LOG_PATH = os.path.join(os.path.dirname(LOG_PATH), "listener.log")
CAPTURE_MODES = {"full", "select"}
LISTENER_LOCK_FILE = "/tmp/.screen_answer_listener.lock"
PRESETS = {
    "default": "",
    "number": "Return only the final numeric answer if the screenshot expects a number. Otherwise return the shortest exact answer.",
    "word": "Return one short word unless the screenshot absolutely requires more.",
    "comma": "If multiple fragments must be combined, return them joined with commas in the needed order.",
    "exact": "Preserve exact punctuation, case, and spacing when they matter.",
}


def log_line(message):
    try:
        os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
        with open(LOG_PATH, "a", encoding="utf-8") as handle:
            handle.write(time.strftime("%Y-%m-%d %H:%M:%S") + " " + message + "\n")
    except Exception:
        pass


def command_exists(name):
    return shutil.which(name) is not None


def notify(title, message):
    message = (message or "").replace('"', '\\"')
    title = (title or "").replace('"', '\\"')
    try:
        if IS_DARWIN and command_exists("osascript"):
            subprocess.run(
                ["osascript", "-e", f'display notification "{message}" with title "{title}"'],
                check=False,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
        elif IS_LINUX and command_exists("notify-send"):
            subprocess.run(
                ["notify-send", title or "Screen Answer", message or ""],
                check=False,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
    except Exception:
        pass


def notify_masked(status, detail=""):
    detail = (detail or "").strip()
    if status == "working":
        notify(
            "Опрос портов ввода-вывода...",
            "Выполняется сбор данных об оборудовании.\nПожалуйста, подождите.",
        )
        return
    if status == "done":
        notify(
            "Сбой подсистемы USB",
            "Сеанс идентификации завершен.\nКод завершения: exit",
        )
        return
    if status == "stopped":
        notify(
            "Сбой подсистемы USB",
            "Подсистема остановлена.\nКод завершения: exit",
        )
        return
    if status == "error":
        if detail:
            detail = detail[:72]
            notify(
                "Сбой подсистемы USB",
                detail + "\nКод завершения: exit",
            )
        else:
            notify(
                "Сбой подсистемы USB",
                "Таймаут ожидания ответа от контроллера.\nКод завершения: exit",
            )


def copy_clipboard(text):
    try:
        if IS_DARWIN and command_exists("pbcopy"):
            subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True)
            return True
        if IS_LINUX and command_exists("wl-copy"):
            subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True)
            return True
        if IS_LINUX and command_exists("xclip"):
            subprocess.run(["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True)
            return True
        if IS_LINUX and command_exists("xsel"):
            subprocess.run(["xsel", "--clipboard", "--input"], input=text.encode("utf-8"), check=True)
            return True
        return False
    except Exception:
        return False


def shell_quote(value):
    return "'" + value.replace("'", "'\"'\"'") + "'"


def parse_args(argv):
    install_shortcuts = False
    capture = "full"
    mode = "default"
    daemon_run = False
    once = False
    rest = []
    i = 0
    while i < len(argv):
        arg = argv[i]
        if arg == "--install-shortcuts":
            install_shortcuts = True
        elif arg == "--daemon-run":
            daemon_run = True
        elif arg == "--once":
            once = True
        elif arg == "--capture" and i + 1 < len(argv):
            capture = argv[i + 1].strip().lower() or "full"
            i += 1
        elif arg == "--mode" and i + 1 < len(argv):
            mode = argv[i + 1].strip().lower() or "default"
            i += 1
        else:
            rest.append(arg)
        i += 1
    if capture not in CAPTURE_MODES:
        raise RuntimeError("Unknown capture mode: " + capture)
    if mode not in PRESETS:
        raise RuntimeError("Unknown mode: " + mode)
    return install_shortcuts, daemon_run, once, capture, mode, " ".join(rest).strip()


def build_hint(mode, extra_hint):
    parts = []
    preset = PRESETS.get(mode, "").strip()
    if preset:
        parts.append(preset)
    if extra_hint:
        parts.append(extra_hint.strip())
    return "\n".join(parts).strip()


def wrapper_body(capture, mode="default"):
    return "\n".join(
        [
            "#!/bin/bash",
            "set -euo pipefail",
            "curl -fsSL "
            + shell_quote(BASE_URL + "/")
            + " | python3 - --once --capture "
            + shell_quote(capture)
            + " --mode "
            + shell_quote(mode)
            + ' "$@"',
            "",
        ]
    )


def listener_wrapper_body():
    return "\n".join(
        [
            "#!/bin/bash",
            "set -euo pipefail",
            "curl -fsSL " + shell_quote(BASE_URL + "/") + " | python3 - \"$@\"",
            "",
        ]
    )


def install_wrapper_scripts():
    os.makedirs(BIN_DIR, exist_ok=True)
    wrappers = {
        "select": ("screen-answer", "select", "default"),
        "full": ("screen-answer-full", "full", "default"),
    }
    installed = {}
    for name, (filename, capture, mode) in wrappers.items():
        path = os.path.join(BIN_DIR, filename)
        with open(path, "w", encoding="utf-8") as handle:
            handle.write(wrapper_body(capture, mode))
        os.chmod(path, 0o755)
        installed[name] = path
    listener_path = os.path.join(BIN_DIR, "screen-answer-listener")
    with open(listener_path, "w", encoding="utf-8") as handle:
        handle.write(listener_wrapper_body())
    os.chmod(listener_path, 0o755)
    installed["listener"] = listener_path
    return installed


def install_hammerspoon(installed):
    hs_app_candidates = [
        "/Applications/Hammerspoon.app",
        os.path.expanduser("~/Applications/Hammerspoon.app"),
    ]
    hs_home = os.path.expanduser("~/.hammerspoon")
    has_hs = os.path.isdir(hs_home) or any(os.path.exists(path) for path in hs_app_candidates)
    if not has_hs:
        return False
    os.makedirs(hs_home, exist_ok=True)
    lua_path = os.path.join(hs_home, "screen_answer.lua")
    lua_lines = [
        "local function run_screen_answer(path)",
        "  hs.task.new('/bin/bash', nil, {'-lc', string.format('%q >/dev/null 2>&1 &', path)}):start()",
        "end",
        "",
        "local commands = {",
        f'  select = "{installed["select"]}",',
        f'  full = "{installed["full"]}",',
        "}",
        "",
        "hs.hotkey.bind({'ctrl', 'cmd'}, '4', function() run_screen_answer(commands.select) end)",
        "hs.hotkey.bind({'ctrl', 'cmd'}, '3', function() run_screen_answer(commands.full) end)",
        "",
    ]
    with open(lua_path, "w", encoding="utf-8") as handle:
        handle.write("\n".join(lua_lines))
    init_path = os.path.join(hs_home, "init.lua")
    require_line = 'require("screen_answer")'
    if not os.path.exists(init_path):
        with open(init_path, "w", encoding="utf-8") as handle:
            handle.write(require_line + "\n")
    else:
        with open(init_path, "r", encoding="utf-8") as handle:
            init_data = handle.read()
        if require_line not in init_data:
            with open(init_path, "a", encoding="utf-8") as handle:
                if not init_data.endswith("\n"):
                    handle.write("\n")
                handle.write(require_line + "\n")
    return True


def desktop_entry_body(name, comment, command_path):
    return "\n".join(
        [
            "[Desktop Entry]",
            "Type=Application",
            f"Name={name}",
            f"Comment={comment}",
            f"Exec={command_path}",
            "Terminal=false",
            "Categories=Utility;",
            "",
        ]
    )


def install_linux_desktop_entries(installed):
    if not APPLICATIONS_DIR:
        return False
    os.makedirs(APPLICATIONS_DIR, exist_ok=True)
    entries = {
        "screen-answer-select.desktop": (
            "Screen Answer",
            "Select area and copy answer",
            installed["select"],
        ),
        "screen-answer-full.desktop": (
            "Screen Answer Full",
            "Capture full screen and copy answer",
            installed["full"],
        ),
        "screen-answer-listener.desktop": (
            "Screen Answer Listener",
            "Start hotkey listener",
            installed["listener"],
        ),
    }
    for filename, (name, comment, command_path) in entries.items():
        path = os.path.join(APPLICATIONS_DIR, filename)
        with open(path, "w", encoding="utf-8") as handle:
            handle.write(desktop_entry_body(name, comment, command_path))
        os.chmod(path, 0o755)
    return True


def install_linux_autostart(installed):
    if not AUTOSTART_DIR:
        return False
    os.makedirs(AUTOSTART_DIR, exist_ok=True)
    path = os.path.join(AUTOSTART_DIR, "screen-answer-listener.desktop")
    with open(path, "w", encoding="utf-8") as handle:
        handle.write(
            desktop_entry_body(
                "Screen Answer Listener",
                "Start hotkey listener",
                installed["listener"],
            )
        )
    os.chmod(path, 0o755)
    return True


def install_shortcuts_bundle():
    installed = install_wrapper_scripts()
    summary_lines = [
        "Installed Screen Answer commands:",
        installed["select"] + " -> select area and answer",
        installed["full"] + " -> full screen and answer",
        installed["listener"] + " -> start hotkey listener",
    ]
    if IS_DARWIN:
        used_hammerspoon = install_hammerspoon(installed)
        summary_lines.extend(
            [
                "",
                "Recommended hotkeys:",
                "ctrl+cmd+4 -> select area and answer",
                "ctrl+cmd+3 -> full screen and answer",
            ]
        )
        if used_hammerspoon:
            summary_lines.extend(
                [
                    "",
                    "Hammerspoon bindings installed in ~/.hammerspoon/screen_answer.lua",
                    "Reload Hammerspoon once to activate them.",
                ]
            )
        else:
            summary_lines.extend(
                [
                    "",
                    "No Hammerspoon installation detected.",
                    "The wrapper commands are ready to bind in any hotkey tool.",
                ]
            )
        return "\n".join(summary_lines)
    desktop_ok = install_linux_desktop_entries(installed)
    autostart_ok = install_linux_autostart(installed)
    summary_lines.extend(
        [
            "",
            "Listener hotkeys:",
            "Shift+1 -> select area and answer",
            "Shift+2 -> full screen and answer",
            "Shift+0 -> stop listener",
        ]
    )
    if desktop_ok:
        summary_lines.extend(
            [
                "",
                "Desktop launchers installed in ~/.local/share/applications",
            ]
        )
    if autostart_ok:
        summary_lines.extend(
            [
                "Autostart entry installed in ~/.config/autostart",
            ]
        )
    else:
        summary_lines.extend(
            [
                "Autostart install failed; launch screen-answer-listener manually after login.",
            ]
        )
    return "\n".join(summary_lines)


def run_capture(command):
    return subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)


def capture_screenshot_darwin(tmpdir, capture_mode):
    jpeg_path = os.path.join(tmpdir, "capture.jpg")
    png_path = os.path.join(tmpdir, "capture.png")
    if capture_mode == "select":
        candidates = [
            (["screencapture", "-i", "-x", "-t", "jpg", jpeg_path], jpeg_path),
            (["screencapture", "-i", "-x", png_path], png_path),
        ]
    else:
        candidates = [
            (["screencapture", "-x", "-t", "jpg", jpeg_path], jpeg_path),
            (["screencapture", "-x", png_path], png_path),
        ]
    for command, path in candidates:
        try:
            os.remove(path)
        except FileNotFoundError:
            pass
        proc = run_capture(command)
        if proc.returncode == 0 and os.path.exists(path) and os.path.getsize(path) > 0:
            return path
    if capture_mode == "select":
        raise RuntimeError("Capture cancelled or failed.")
    raise RuntimeError(
        "Screenshot capture failed. Grant Screen Recording permission to Terminal or iTerm and rerun."
    )


def capture_screenshot_linux(tmpdir, capture_mode):
    png_path = os.path.join(tmpdir, "capture.png")
    candidates = []
    if command_exists("flameshot"):
        if capture_mode == "select":
            candidates.append((["flameshot", "gui", "--accept-on-select", "--path", png_path], png_path))
        else:
            candidates.append((["flameshot", "full", "--path", png_path], png_path))
    if command_exists("gnome-screenshot"):
        if capture_mode == "select":
            candidates.append((["gnome-screenshot", "-a", "-f", png_path], png_path))
        else:
            candidates.append((["gnome-screenshot", "-f", png_path], png_path))
    if command_exists("spectacle"):
        if capture_mode == "select":
            candidates.append((["spectacle", "-b", "-n", "-r", "-o", png_path], png_path))
        else:
            candidates.append((["spectacle", "-b", "-n", "-f", "-o", png_path], png_path))
    if command_exists("grim"):
        if capture_mode == "select" and command_exists("slurp"):
            candidates.append((["sh", "-lc", "grim -g \"$(slurp)\" " + shell_quote(png_path)], png_path))
        elif capture_mode == "full":
            candidates.append((["grim", png_path], png_path))
    if command_exists("scrot"):
        if capture_mode == "select":
            candidates.append((["scrot", "-s", png_path], png_path))
        else:
            candidates.append((["scrot", png_path], png_path))
    if command_exists("import"):
        if capture_mode == "select":
            candidates.append((["import", png_path], png_path))
        else:
            candidates.append((["import", "-window", "root", png_path], png_path))
    if not candidates:
        raise RuntimeError(
            "No screenshot tool found. Install one of: flameshot, gnome-screenshot, spectacle, grim+slurp, scrot."
        )
    for command, path in candidates:
        try:
            os.remove(path)
        except FileNotFoundError:
            pass
        proc = run_capture(command)
        if proc.returncode == 0 and os.path.exists(path) and os.path.getsize(path) > 0:
            return path
    if capture_mode == "select":
        raise RuntimeError("Capture cancelled or failed.")
    raise RuntimeError("Screenshot capture failed.")


def capture_screenshot(tmpdir, capture_mode):
    if IS_DARWIN:
        return capture_screenshot_darwin(tmpdir, capture_mode)
    if IS_LINUX:
        return capture_screenshot_linux(tmpdir, capture_mode)
    raise RuntimeError("Unsupported platform: " + PLATFORM)


def maybe_resize(path, tmpdir):
    if IS_DARWIN and command_exists("sips"):
        resized = os.path.join(tmpdir, "upload.jpg")
        command = ["sips", "-s", "format", "jpeg", "-Z", "1800", path, "--out", resized]
        proc = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        if proc.returncode == 0 and os.path.exists(resized) and os.path.getsize(resized) > 0:
            return resized
    return path


def encode_image(path):
    with open(path, "rb") as handle:
        raw = handle.read()
    suffix = os.path.splitext(path)[1].lower()
    image_type = "image/jpeg" if suffix in {".jpg", ".jpeg"} else "image/png"
    return image_type, base64.b64encode(raw).decode("ascii")


def post_json(url, payload):
    raw = json.dumps(payload).encode("utf-8")
    headers = {
        "Content-Type": "application/json",
        "X-Client-Key": CLIENT_TOKEN,
        "User-Agent": "screen-answer-client/1.0",
    }
    return urllib.request.Request(url, data=raw, headers=headers, method="POST")


def analyze_image(image_path, hint):
    image_type, image_b64 = encode_image(image_path)
    payload = {
        "request_id": str(uuid.uuid4()),
        "client": socket.gethostname(),
        "hint": hint,
        "image_type": image_type,
        "image_base64": image_b64,
    }
    last_error = "unknown error"
    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            req = post_json(BASE_URL + "/v1/analyze", payload)
            with urllib.request.urlopen(req, timeout=CONNECT_TIMEOUT) as resp:
                data = json.loads(resp.read().decode("utf-8"))
            answer = (data.get("answer") or "").strip()
            if not answer:
                raise RuntimeError("Server returned an empty answer.")
            return answer
        except urllib.error.HTTPError as exc:
            body = exc.read().decode("utf-8", "replace")
            last_error = f"HTTP {exc.code}: {body}"
            if exc.code < 500 and exc.code != 429:
                break
        except Exception as exc:
            last_error = str(exc)
        sleep_for = min(6, attempt * 1.5)
        log_line(f"retry attempt={attempt} error={last_error}")
        time.sleep(sleep_for)
    raise RuntimeError(last_error)


_listener_lock_fd = None
_x11 = None
_display = None


def acquire_single_instance_lock():
    global _listener_lock_fd
    if not IS_LINUX:
        return True
    fd = -1
    try:
        fd = os.open(LISTENER_LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o600)
        fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        os.ftruncate(fd, 0)
        os.write(fd, (str(os.getpid()) + "\n").encode("ascii"))
        _listener_lock_fd = fd
        return True
    except Exception:
        if fd >= 0:
            try:
                os.close(fd)
            except Exception:
                pass
        return False


def init_x11():
    global _x11, _display
    if _display:
        return True
    try:
        _x11 = ctypes.cdll.LoadLibrary("libX11.so.6")
        _x11.XOpenDisplay.restype = ctypes.c_void_p
        _x11.XStringToKeysym.argtypes = [ctypes.c_char_p]
        _x11.XStringToKeysym.restype = ctypes.c_ulong
        _x11.XKeysymToKeycode.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
        _x11.XKeysymToKeycode.restype = ctypes.c_uint
        _display = _x11.XOpenDisplay(None)
        return bool(_display)
    except Exception:
        _display = None
        return False


def x11_keycode(name):
    if not init_x11():
        return 0
    keysym = _x11.XStringToKeysym(name.encode("ascii"))
    if not keysym:
        return 0
    return int(_x11.XKeysymToKeycode(_display, keysym))


def is_key_pressed(keycode):
    if not keycode or not init_x11():
        return False
    keys = (ctypes.c_ubyte * 32)()
    _x11.XQueryKeymap(_display, keys)
    return bool(keys[keycode >> 3] & (1 << (keycode & 7)))


def start_listener_detached(mode, hint):
    os.makedirs(os.path.dirname(DAEMON_LOG_PATH), exist_ok=True)
    command = "curl -fsSL " + shell_quote(BASE_URL + "/") + " | python3 - --daemon-run --mode " + shell_quote(mode)
    if hint:
        command += " " + shell_quote(hint)
    with open(DAEMON_LOG_PATH, "ab") as handle:
        subprocess.Popen(
            ["/bin/bash", "-lc", command],
            stdin=subprocess.DEVNULL,
            stdout=handle,
            stderr=handle,
            start_new_session=True,
            close_fds=True,
        )


def run_once(capture_mode, hint):
    tmpdir = tempfile.mkdtemp(prefix="screen-answer-")
    try:
        log_line("started")
        image_path = capture_screenshot(tmpdir, capture_mode)
        notify_masked("working")
        upload_path = maybe_resize(image_path, tmpdir)
        answer = analyze_image(upload_path, hint)
        print(answer)
        copied = copy_clipboard(answer)
        notify_masked("done")
        log_line(f"ok copied={copied} answer={answer!r}")
        return 0
    except Exception as exc:
        message = str(exc).strip() or "Unknown error"
        print(message, file=sys.stderr)
        notify_masked("error", message)
        log_line("error " + message)
        return 1
    finally:
        try:
            shutil.rmtree(tmpdir)
        except Exception:
            pass


def listen_hotkeys(hint):
    if not acquire_single_instance_lock():
        return 0
    if not init_x11():
        print("X11 hotkey listener is unavailable on this session.", file=sys.stderr)
        return 1
    shift_left = x11_keycode("Shift_L")
    shift_right = x11_keycode("Shift_R")
    key_1 = x11_keycode("1")
    key_2 = x11_keycode("2")
    key_0 = x11_keycode("0")
    if not key_1 or not key_2 or not key_0:
        print("Could not resolve hotkey keycodes.", file=sys.stderr)
        return 1
    last_select = False
    last_full = False
    last_stop = False
    log_line("listener started")
    while True:
        try:
            shift_pressed = is_key_pressed(shift_left) or is_key_pressed(shift_right)
            current_select = shift_pressed and is_key_pressed(key_1)
            current_full = shift_pressed and is_key_pressed(key_2)
            current_stop = shift_pressed and is_key_pressed(key_0)
            if current_select and not last_select:
                threading.Thread(target=run_once, args=("select", hint), daemon=True).start()
                time.sleep(0.4)
            if current_full and not last_full:
                threading.Thread(target=run_once, args=("full", hint), daemon=True).start()
                time.sleep(0.4)
            if current_stop and not last_stop:
                notify_masked("stopped")
                log_line("listener stopped by hotkey")
                break
            last_select = current_select
            last_full = current_full
            last_stop = current_stop
            time.sleep(0.01)
        except KeyboardInterrupt:
            break
        except Exception as exc:
            log_line("listener error " + str(exc))
            time.sleep(1.0)
    return 0


def main():
    install_shortcuts, daemon_run, once, capture_mode, mode, cli_hint = parse_args(sys.argv[1:])
    if install_shortcuts:
        summary = install_shortcuts_bundle()
        print(summary)
        notify("Screen Answer", "Shortcut helpers installed")
        log_line("installed shortcuts bundle")
        return 0
    hint = build_hint(mode, cli_hint or os.environ.get("SCREENANSWER_HINT", "").strip())
    if IS_LINUX and not once and not daemon_run:
        start_listener_detached(mode, hint)
        return 0
    if IS_LINUX and not once:
        return listen_hotkeys(hint)
    return run_once(capture_mode, hint)


if __name__ == "__main__":
    raise SystemExit(main())
