#!/usr/bin/env python3
"""cosmic-translate-shot: screenshot-region translator for COSMIC (Wayland).

Flow: capture all outputs via ext_image_copy_capture -> fullscreen selection
overlay per monitor (drag a box, Esc cancels) -> crop -> Claude reads and
translates the text -> popup with the translation anchored where you selected
(layer-shell margins), also copied to the clipboard.

Translation engines, in order:
  1. The Anthropic Messages API with your own API key (ANTHROPIC_API_KEY in
     the environment or in ~/.config/cosmic-tools/translate-shot.conf).
  2. The `claude` CLI (Claude Code), if it is installed and no key is set.

    cosmic-translate-shot                 select a region and translate it
    cosmic-translate-shot --file IMG      translate an image file, print result
    cosmic-translate-shot --check         show the engine and settings in use
"""

import array
import base64
import io
import json
import mmap
import os
import re
import shutil
import socket
import stat
import struct
import subprocess
import sys
import tempfile
import threading
import time
import urllib.error
import urllib.request

VERSION = '1.0.0'
PROG = 'cosmic-translate-shot'
WL_SHM_XRGB8888 = 1

CONFIG_HOME = os.environ.get('XDG_CONFIG_HOME') or os.path.expanduser('~/.config')
CONFIG_FILE = os.path.join(CONFIG_HOME, 'cosmic-tools', 'translate-shot.conf')

DEFAULTS = {
    'ANTHROPIC_API_KEY': '',
    'TRANSLATE_SHOT_ENGINE': 'auto',        # auto | api | cli
    'TRANSLATE_SHOT_MODEL': 'claude-sonnet-5',
    'TRANSLATE_SHOT_LANG': 'English',
    'TRANSLATE_SHOT_API_URL': 'https://api.anthropic.com',
    'TRANSLATE_SHOT_MAX_TOKENS': '4096',
    'TRANSLATE_SHOT_TIMEOUT': '120',
    'TRANSLATE_SHOT_CLAUDE': '',            # path to the claude CLI
}

TASK = (
    'Extract all text from this screenshot and translate it into natural, '
    'fluent {lang}. Reconstruct sentences that wrap across multiple lines '
    '(including hyphenated word breaks) before translating: translate '
    'meaning, never line by line. Keep meaningful structure: separate '
    'paragraphs, list items, buttons and labels each on their own line. '
    'Transcribe numbers, names, and references exactly as shown. Output '
    'ONLY the {lang} translation, no commentary. If the text is already in '
    '{lang}, output it as-is. If there is no legible text, output '
    'exactly: (no text found)'
)

GUI_HINT = (
    'Missing dependency: %s\n'
    'Debian/Ubuntu/Pop: sudo apt install python3-gi gir1.2-gtk-3.0 '
    'gir1.2-gtklayershell-0.1 python3-pil\n'
    'Fedora: sudo dnf install python3-gobject gtk3 gtk-layer-shell '
    'python3-pillow\n')


def runtime_dir():
    return os.environ.get('XDG_RUNTIME_DIR') or tempfile.gettempdir()


def log_failure(msg):
    try:
        with open(os.path.join(runtime_dir(), PROG + '.log'), 'a') as lf:
            lf.write('--- %s\n%s\n' % (time.strftime('%F %T'), msg))
    except OSError:
        pass


# ----------------------------------------------------------------- config ---

def parse_conf(text):
    """KEY=VALUE lines; '#' comments; optional single or double quotes."""
    out = {}
    for raw in text.splitlines():
        line = raw.strip()
        if not line or line.startswith('#'):
            continue
        if line.startswith('export '):
            line = line[7:].lstrip()
        key, sep, val = line.partition('=')
        key = key.strip()
        if not sep or not re.match(r'^[A-Z_][A-Z0-9_]*$', key):
            continue
        val = val.strip()
        if len(val) >= 2 and val[0] == val[-1] and val[0] in '"\'':
            val = val[1:-1]
        else:
            val = val.split(' #', 1)[0].strip()
        out[key] = val
    return out


def load_config(path=None, environ=None, warn=True):
    """Defaults <- config file <- environment. Returns a dict."""
    path = path or CONFIG_FILE
    environ = os.environ if environ is None else environ
    cfg = dict(DEFAULTS)
    try:
        with open(path, encoding='utf-8') as fh:
            file_vals = parse_conf(fh.read())
    except OSError:
        file_vals = {}
    if file_vals.get('ANTHROPIC_API_KEY') and warn:
        try:
            mode = stat.S_IMODE(os.stat(path).st_mode)
            if mode & 0o077:
                sys.stderr.write(
                    '%s: warning: %s holds an API key but is readable by '
                    'others (mode %o). Run: chmod 600 %s\n'
                    % (PROG, path, mode, path))
        except OSError:
            pass
    for k in DEFAULTS:
        if file_vals.get(k):
            cfg[k] = file_vals[k]
        if environ.get(k):
            cfg[k] = environ[k]
    return cfg


def find_claude(cfg):
    """Locate the claude CLI; COSMIC's Spawn env may have a minimal PATH."""
    cand = cfg.get('TRANSLATE_SHOT_CLAUDE') or shutil.which('claude')
    if cand and os.access(cand, os.X_OK):
        return cand
    for p in ('~/.local/bin/claude', '~/.npm-global/bin/claude',
              '~/.claude/local/claude', '/usr/local/bin/claude',
              '/usr/bin/claude'):
        p = os.path.expanduser(p)
        if os.access(p, os.X_OK):
            return p
    return None


def choose_engine(cfg):
    """'api', 'cli', or None."""
    engine = cfg.get('TRANSLATE_SHOT_ENGINE', 'auto')
    if engine == 'api':
        return 'api' if cfg.get('ANTHROPIC_API_KEY') else None
    if engine == 'cli':
        return 'cli' if find_claude(cfg) else None
    if cfg.get('ANTHROPIC_API_KEY'):
        return 'api'
    if find_claude(cfg):
        return 'cli'
    return None


# ---------------------------------------------------------------- engines ---

def media_type_for(data):
    if data[:8] == b'\x89PNG\r\n\x1a\n':
        return 'image/png'
    if data[:3] == b'\xff\xd8\xff':
        return 'image/jpeg'
    if data[:6] in (b'GIF87a', b'GIF89a'):
        return 'image/gif'
    if data[:4] == b'RIFF' and data[8:12] == b'WEBP':
        return 'image/webp'
    raise ValueError('unsupported image type (use PNG, JPEG, GIF or WebP)')


def build_request(image_bytes, cfg):
    """(url, headers, body_bytes) for one Messages API call."""
    body = {
        'model': cfg['TRANSLATE_SHOT_MODEL'],
        'max_tokens': int(cfg['TRANSLATE_SHOT_MAX_TOKENS']),
        'messages': [{'role': 'user', 'content': [
            {'type': 'image', 'source': {
                'type': 'base64',
                'media_type': media_type_for(image_bytes),
                'data': base64.b64encode(image_bytes).decode('ascii')}},
            {'type': 'text',
             'text': TASK.format(lang=cfg['TRANSLATE_SHOT_LANG'])},
        ]}],
    }
    headers = {
        'content-type': 'application/json',
        'anthropic-version': '2023-06-01',
        'x-api-key': cfg['ANTHROPIC_API_KEY'],
        'user-agent': '%s/%s' % (PROG, VERSION),
    }
    url = cfg['TRANSLATE_SHOT_API_URL'].rstrip('/') + '/v1/messages'
    return url, headers, json.dumps(body).encode('utf-8')


def parse_response(resp):
    if resp.get('type') == 'error':
        raise RuntimeError(resp.get('error', {}).get('message', 'API error'))
    text = '\n'.join(b.get('text', '') for b in resp.get('content', [])
                     if b.get('type') == 'text').strip()
    if resp.get('stop_reason') == 'max_tokens':
        text += '\n[translation truncated]'
    return text or '(no text found)'


def translate_api(img_path, cfg):
    with open(img_path, 'rb') as f:
        url, headers, data = build_request(f.read(), cfg)
    req = urllib.request.Request(url, data=data, headers=headers,
                                 method='POST')
    try:
        with urllib.request.urlopen(
                req, timeout=float(cfg['TRANSLATE_SHOT_TIMEOUT'])) as r:
            resp = json.load(r)
    except urllib.error.HTTPError as e:
        detail = ''
        try:
            detail = json.loads(e.read().decode('utf-8', 'replace')) \
                .get('error', {}).get('message', '')
        except (OSError, ValueError):
            pass
        raise RuntimeError('API returned HTTP %d%s'
                           % (e.code, (': ' + detail) if detail else ''))
    return parse_response(resp)


def cli_command(claude, img_path, cfg):
    prompt = ('Read the image at %s . ' % img_path
              + TASK.format(lang=cfg['TRANSLATE_SHOT_LANG']))
    return [claude, '-p', prompt, '--model', cfg['TRANSLATE_SHOT_MODEL'],
            '--allowedTools', 'Read']


def translate_cli(img_path, cfg):
    claude = find_claude(cfg)
    if claude is None:
        return ('claude CLI not found. Install Claude Code '
                '(https://claude.com/claude-code) or set ANTHROPIC_API_KEY.')
    # Drop variables that would misroute a nested CLI run (for example when
    # this is started from inside another Claude Code session).
    env = {k: v for k, v in os.environ.items()
           if not k.startswith(('CLAUDECODE', 'CLAUDE_CODE_'))}
    proc = subprocess.run(
        cli_command(claude, img_path, cfg),
        capture_output=True, text=True,
        timeout=float(cfg['TRANSLATE_SHOT_TIMEOUT']),
        stdin=subprocess.DEVNULL, cwd=os.path.dirname(img_path), env=env)
    text = proc.stdout.strip()
    if proc.returncode != 0 or not text:
        err = proc.stderr.strip() or proc.stdout.strip() or 'no output'
        log_failure('cli rc=%s\nSTDOUT:%s\nSTDERR:%s'
                    % (proc.returncode, proc.stdout[-2000:],
                       proc.stderr[-2000:]))
        if 'Not logged in' in err or 'login' in err.lower():
            return ('The claude CLI is not logged in.\nOpen a terminal and '
                    'run: claude, then follow the login prompt.')
        return 'Translation failed:\n' + err[:500]
    return text


def translate(img_path, cfg):
    engine = choose_engine(cfg)
    if engine == 'api':
        try:
            return translate_api(img_path, cfg)
        except Exception as e:  # noqa: BLE001
            log_failure('api error: %r' % e)
            if cfg.get('TRANSLATE_SHOT_ENGINE') == 'auto' and find_claude(cfg):
                return translate_cli(img_path, cfg)
            return 'Translation failed: %s' % e
    if engine == 'cli':
        return translate_cli(img_path, cfg)
    return ('No translation engine configured. Either:\n'
            '- put ANTHROPIC_API_KEY=... in %s (chmod 600), or\n'
            '- install Claude Code (https://claude.com/claude-code) and log in.'
            % CONFIG_FILE)


def load_gui():
    """Import GTK/Pillow lazily so --check, --file and tests need no GUI."""
    global Gtk, Gdk, GdkPixbuf, GLib, GtkLayerShell, Pango, Image
    try:
        import gi
        gi.require_version('Gtk', '3.0')
        gi.require_version('GtkLayerShell', '0.1')
        from gi.repository import (Gtk, Gdk, GdkPixbuf, GLib,  # noqa: F811
                                   GtkLayerShell, Pango)
        from PIL import Image  # noqa: F811
    except (ImportError, ValueError) as e:
        sys.stderr.write(GUI_HINT % e)
        sys.exit(1)


# ---------------------------------------------------------------- wayland ---

class WaylandError(Exception):
    pass


class WaylandConnection:
    """Minimal wire-protocol client, just enough for output capture."""

    def __init__(self):
        path = os.path.join(os.environ['XDG_RUNTIME_DIR'],
                            os.environ.get('WAYLAND_DISPLAY', 'wayland-0'))
        self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        self.sock.connect(path)
        self.next_id = 2
        self.handlers = {}   # object id -> callback(opcode, payload)
        self.buf = b''
        self.out = b''
        self.out_fds = []

    def new_id(self, handler=None):
        oid = self.next_id
        self.next_id += 1
        if handler:
            self.handlers[oid] = handler
        return oid

    def send(self, obj, op, payload=b'', fds=()):
        self.out += struct.pack('<IHH', obj, op, 8 + len(payload)) + payload
        self.out_fds.extend(fds)

    def flush(self):
        if not self.out:
            return
        if self.out_fds:
            anc = [(socket.SOL_SOCKET, socket.SCM_RIGHTS,
                    array.array('i', self.out_fds))]
            self.sock.sendmsg([self.out], anc)
        else:
            self.sock.send(self.out)
        self.out = b''
        self.out_fds = []

    @staticmethod
    def wl_string(s):
        b = s.encode() + b'\0'
        pad = (-len(b)) % 4
        return struct.pack('<I', len(b)) + b + b'\0' * pad

    def dispatch_until(self, pred, timeout=5.0):
        self.flush()
        self.sock.settimeout(timeout)
        while not pred():
            data = self.sock.recv(65536)
            if not data:
                raise WaylandError('display connection closed')
            self.buf += data
            while len(self.buf) >= 8:
                obj, = struct.unpack('<I', self.buf[:4])
                op, = struct.unpack('<H', self.buf[4:6])
                size, = struct.unpack('<H', self.buf[6:8])
                if len(self.buf) < size:
                    break
                payload = self.buf[8:size]
                self.buf = self.buf[size:]
                if obj == 1 and op == 0:  # wl_display.error
                    code, = struct.unpack('<I', payload[4:8])
                    slen, = struct.unpack('<I', payload[8:12])
                    raise WaylandError(
                        'protocol error %d: %s'
                        % (code, payload[12:12 + slen - 1].decode()))
                h = self.handlers.get(obj)
                if h:
                    h(op, payload)

    def sync(self):
        done = []
        cb = self.new_id(lambda op, p: done.append(1))
        self.send(1, 0, struct.pack('<I', cb))
        self.dispatch_until(lambda: done)


def capture_outputs():
    """Return list of dicts: name, x, y, w, h (logical), image (PIL)."""
    c = WaylandConnection()

    registry_globals = []
    registry = c.new_id()

    def on_registry(op, p):
        if op == 0:
            name, = struct.unpack('<I', p[:4])
            slen, = struct.unpack('<I', p[4:8])
            iface = p[8:8 + slen - 1].decode()
            off = 8 + slen + ((-slen) % 4)
            ver, = struct.unpack('<I', p[off:off + 4])
            registry_globals.append((name, iface, ver))

    c.handlers[registry] = on_registry
    c.send(1, 1, struct.pack('<I', registry))
    c.sync()

    def bind(iface, version):
        oid = c.new_id()
        for name, i, v in registry_globals:
            if i == iface:
                c.send(registry, 0, struct.pack('<I', name)
                       + c.wl_string(iface)
                       + struct.pack('<II', min(version, v), oid))
                return oid
        raise WaylandError('compositor lacks ' + iface)

    shm = bind('wl_shm', 1)
    capture_mgr = bind('ext_image_copy_capture_manager_v1', 1)
    source_mgr = bind('ext_output_image_capture_source_manager_v1', 1)
    xdg_out_mgr = bind('zxdg_output_manager_v1', 3)

    outputs = []
    for name, iface, v in registry_globals:
        if iface != 'wl_output':
            continue
        oid = c.new_id()
        c.send(registry, 0, struct.pack('<I', name)
               + c.wl_string('wl_output') + struct.pack('<II', min(4, v), oid))
        info = {'wl': oid, 'name': '?', 'x': 0, 'y': 0, 'w': 0, 'h': 0}

        def on_output(op, p, info=info):
            if op == 4:  # name
                slen, = struct.unpack('<I', p[:4])
                info['name'] = p[4:4 + slen - 1].decode()

        c.handlers[oid] = on_output
        xo = c.new_id()

        def on_xdg(op, p, info=info):
            if op == 0:
                info['x'], info['y'] = struct.unpack('<ii', p)
            elif op == 1:
                info['w'], info['h'] = struct.unpack('<ii', p)

        c.handlers[xo] = on_xdg
        c.send(xdg_out_mgr, 1, struct.pack('<II', xo, oid))
        outputs.append(info)
    c.sync()

    for info in outputs:
        src = c.new_id()
        c.send(source_mgr, 0, struct.pack('<II', src, info['wl']))
        sess = c.new_id()
        state = {'w': 0, 'h': 0, 'formats': [], 'done': False}

        def on_sess(op, p, state=state):
            if op == 0:
                state['w'], state['h'] = struct.unpack('<II', p)
            elif op == 1:
                state['formats'].append(struct.unpack('<I', p)[0])
            elif op == 4:
                state['done'] = True

        c.handlers[sess] = on_sess
        c.send(capture_mgr, 0, struct.pack('<III', sess, src, 0))
        c.dispatch_until(lambda s=state: s['done'])

        w, h = state['w'], state['h']
        fmt = WL_SHM_XRGB8888 if WL_SHM_XRGB8888 in state['formats'] \
            else state['formats'][0]
        stride = w * 4
        size = stride * h
        fd = os.memfd_create('cosmic-translate-shot')
        os.ftruncate(fd, size)
        pool = c.new_id()
        c.send(shm, 0, struct.pack('<II', pool, size), fds=[fd])
        wl_buf = c.new_id()
        c.send(pool, 0, struct.pack('<IiiiiI', wl_buf, 0, w, h, stride, fmt))

        frame = c.new_id()
        fstate = {'status': None}

        def on_frame(op, p, fstate=fstate):
            if op == 3:
                fstate['status'] = 'ready'
            elif op == 4:
                fstate['status'] = 'failed'

        c.handlers[frame] = on_frame
        c.send(sess, 0, struct.pack('<I', frame))
        c.send(frame, 1, struct.pack('<I', wl_buf))
        c.send(frame, 3)
        c.dispatch_until(lambda s=fstate: s['status'] is not None)
        if fstate['status'] != 'ready':
            os.close(fd)
            raise WaylandError('capture failed on output ' + info['name'])

        with mmap.mmap(fd, size) as m:
            mode_map = {0: 'BGRA', 1: 'BGRX'}
            raw = mode_map.get(fmt, 'BGRX')
            img = Image.frombuffer('RGB', (w, h), m.read(size),
                                   'raw', raw, stride, 1)
        os.close(fd)
        info['image'] = img
        # frame + session cleanup
        c.send(frame, 0)
        c.send(sess, 1)
        c.flush()

    c.sock.close()
    return [o for o in outputs if o['w'] > 0]


# -------------------------------------------------------------------- gtk ---

def pil_to_pixbuf(img):
    buf = io.BytesIO()
    img.save(buf, 'ppm')
    loader = GdkPixbuf.PixbufLoader.new_with_type('pnm')
    loader.write(buf.getvalue())
    loader.close()
    return loader.get_pixbuf()


class App:
    def __init__(self):
        self.outputs = capture_outputs()
        self.overlays = []
        self.result = None  # (output, x, y, w, h) local logical coords

    # ---- selection overlays ----

    def run(self):
        display = Gdk.Display.get_default()
        monitors = {}
        for i in range(display.get_n_monitors()):
            mon = display.get_monitor(i)
            g = mon.get_geometry()
            monitors[(g.x, g.y)] = mon

        for out in self.outputs:
            mon = monitors.get((out['x'], out['y']))
            win = self.make_overlay(out, mon)
            self.overlays.append(win)
            win.show_all()
        Gtk.main()

    def make_overlay(self, out, monitor):
        win = Gtk.Window()
        GtkLayerShell.init_for_window(win)
        GtkLayerShell.set_layer(win, GtkLayerShell.Layer.OVERLAY)
        GtkLayerShell.set_keyboard_mode(
            win, GtkLayerShell.KeyboardMode.EXCLUSIVE)
        if monitor is not None:
            GtkLayerShell.set_monitor(win, monitor)
        for e in (GtkLayerShell.Edge.TOP, GtkLayerShell.Edge.BOTTOM,
                  GtkLayerShell.Edge.LEFT, GtkLayerShell.Edge.RIGHT):
            GtkLayerShell.set_anchor(win, e, True)
        GtkLayerShell.set_exclusive_zone(win, -1)

        # pre-render normal + dimmed screenshot at logical size
        shot = out['image']
        if shot.size != (out['w'], out['h']):
            shot = shot.resize((out['w'], out['h']), Image.LANCZOS)
        dim = Image.blend(shot, Image.new('RGB', shot.size, 0), 0.45)
        norm_pb = pil_to_pixbuf(shot)
        dim_pb = pil_to_pixbuf(dim)

        sel = {'active': False, 'x0': 0, 'y0': 0, 'x1': 0, 'y1': 0}

        fixed = Gtk.Fixed()
        bg = Gtk.Image.new_from_pixbuf(dim_pb)
        fixed.put(bg, 0, 0)
        sel_img = Gtk.Image()
        sel_img.get_style_context().add_class('ts-sel')
        fixed.put(sel_img, 0, 0)
        sel_img.set_no_show_all(True)

        ebox = Gtk.EventBox()
        ebox.add(fixed)
        ebox.add_events(Gdk.EventMask.BUTTON_PRESS_MASK
                        | Gdk.EventMask.BUTTON_RELEASE_MASK
                        | Gdk.EventMask.POINTER_MOTION_MASK)

        def rect():
            x = int(min(sel['x0'], sel['x1']))
            y = int(min(sel['y0'], sel['y1']))
            w = int(abs(sel['x1'] - sel['x0']))
            h = int(abs(sel['y1'] - sel['y0']))
            x = max(0, min(x, out['w'] - 1))
            y = max(0, min(y, out['h'] - 1))
            w = min(w, out['w'] - x)
            h = min(h, out['h'] - y)
            return x, y, w, h

        def update_sel():
            x, y, w, h = rect()
            if w < 2 or h < 2:
                sel_img.hide()
                return
            sub = GdkPixbuf.Pixbuf.new_subpixbuf(norm_pb, x, y, w, h)
            sel_img.set_from_pixbuf(sub)
            fixed.move(sel_img, x, y)
            sel_img.show()

        def press(w_, ev):
            sel['active'] = True
            sel['x0'] = sel['x1'] = ev.x
            sel['y0'] = sel['y1'] = ev.y
            update_sel()

        def motion(w_, ev):
            if sel['active']:
                sel['x1'], sel['y1'] = ev.x, ev.y
                update_sel()

        def release(w_, ev):
            if not sel['active']:
                return
            sel['active'] = False
            sel['x1'], sel['y1'] = ev.x, ev.y
            x, y, w, h = rect()
            if w < 8 or h < 8:
                self.finish(None)
                return
            self.result = (out, x, y, w, h)
            self.finish(self.result)

        ebox.connect('button-press-event', press)
        ebox.connect('motion-notify-event', motion)
        ebox.connect('button-release-event', release)

        def key(w_, ev):
            if ev.keyval == Gdk.KEY_Escape:
                self.finish(None)

        win.connect('key-press-event', key)
        win.add(ebox)
        win.set_default_size(out['w'], out['h'])

        css = Gtk.CssProvider()
        css.load_from_data(b'.ts-sel { border: 2px solid #f5c242; }')
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(), css,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        cursor = Gdk.Cursor.new_from_name(Gdk.Display.get_default(),
                                          'crosshair')
        win.connect('realize',
                    lambda w_: w_.get_window().set_cursor(cursor))
        return win

    def finish(self, result):
        for w in self.overlays:
            w.destroy()
        self.overlays = []
        if result is None:
            Gtk.main_quit()
            return
        out, x, y, w, h = result
        scale = out['image'].width / out['w']
        crop = out['image'].crop((int(x * scale), int(y * scale),
                                  int((x + w) * scale),
                                  int((y + h) * scale)))
        # small crops OCR poorly; upscale so on-screen text reads cleanly
        if max(crop.size) < 1000:
            crop = crop.resize((crop.width * 2, crop.height * 2),
                               Image.LANCZOS)
        fd, path = tempfile.mkstemp(suffix='.png', prefix='cosmic-translate-shot-')
        os.close(fd)
        crop.save(path)
        self.show_popup(out, x, y, w, h, path)

    # ---- translation popup ----

    def show_popup(self, out, x, y, w, h, img_path):
        win = Gtk.Window()
        GtkLayerShell.init_for_window(win)
        GtkLayerShell.set_layer(win, GtkLayerShell.Layer.OVERLAY)
        GtkLayerShell.set_keyboard_mode(
            win, GtkLayerShell.KeyboardMode.ON_DEMAND)
        display = Gdk.Display.get_default()
        for i in range(display.get_n_monitors()):
            mon = display.get_monitor(i)
            g = mon.get_geometry()
            if (g.x, g.y) == (out['x'], out['y']):
                GtkLayerShell.set_monitor(win, mon)
                break
        GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.TOP, True)
        GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.LEFT, True)
        GtkLayerShell.set_exclusive_zone(win, -1)
        pos = {'x': max(0, min(x, out['w'] - 240)),
               'y': max(0, min(y, out['h'] - 120))}
        GtkLayerShell.set_margin(win, GtkLayerShell.Edge.LEFT, pos['x'])
        GtkLayerShell.set_margin(win, GtkLayerShell.Edge.TOP, pos['y'])

        drag = {'on': False, 'ox': 0, 'oy': 0}

        def place(alloc=None):
            if drag['on']:
                return
            if alloc is None:
                alloc = win.get_allocation()
            pos['x'] = max(0, min(pos['x'], out['w'] - alloc.width))
            pos['y'] = max(0, min(pos['y'], out['h'] - alloc.height))
            GtkLayerShell.set_margin(win, GtkLayerShell.Edge.LEFT, pos['x'])
            GtkLayerShell.set_margin(win, GtkLayerShell.Edge.TOP, pos['y'])

        # re-clamp to the popup's real size so it never runs off the
        # monitor (long translations previously pushed it off-screen)
        win.connect('size-allocate', lambda w_, alloc: place(alloc))

        frame = Gtk.Frame()
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
        box.set_border_width(12)

        # header row: drag grip + close button (layer-shell surfaces can't
        # be moved by the compositor, so dragging updates the margins)
        grip = Gtk.Label()
        grip.set_markup('<small>⠿ drag to move · Esc to close</small>')
        grip.set_xalign(0)
        grip.get_style_context().add_class('dim-label')
        close = Gtk.Button(label='×')
        close.set_relief(Gtk.ReliefStyle.NONE)
        close.connect('clicked', lambda *_: Gtk.main_quit())
        header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        header.pack_start(grip, True, True, 0)
        header.pack_end(close, False, False, 0)
        hbar = Gtk.EventBox()
        hbar.add(header)
        hbar.add_events(Gdk.EventMask.BUTTON_PRESS_MASK
                        | Gdk.EventMask.BUTTON_RELEASE_MASK
                        | Gdk.EventMask.POINTER_MOTION_MASK)
        # Dragging by moving the layer surface itself feeds back (the
        # compositor sends no motion event when the surface shifts under
        # a stationary pointer, so deltas re-apply and the popup runs to
        # a corner). Instead: while dragging, anchor the surface to the
        # whole monitor (invisible — the window background is
        # transparent) and move the visible frame inside it. Pointer
        # coords are then absolute and drift-free; on release the
        # surface shrinks back to the frame at its new margins.
        def dlog(kind, ev):
            if not os.environ.get('TRANSLATE_SHOT_DRAG_DEBUG'):
                return
            wa = win.get_allocation()
            fa = frame.get_allocation()
            with open(os.path.join(runtime_dir(), 'cosmic-translate-shot-drag.log'), 'a') as f:
                f.write('%s x=%.0f y=%.0f xr=%.0f yr=%.0f pos=%d,%d '
                        'win=%dx%d frame=%dx%d\n'
                        % (kind, ev.x, ev.y, ev.x_root, ev.y_root,
                           pos['x'], pos['y'], wa.width, wa.height,
                           fa.width, fa.height))

        def h_press(w_, ev):
            dlog('press', ev)
            drag['on'] = True
            # cosmic's pointer stream is incremental: coords don't
            # re-derive when the surface expands, so x_root deltas are
            # the physical pointer motion. Baseline on the first motion
            # event (not the press) so a compositor that does re-derive
            # after the expansion is also handled.
            drag['ox'] = None
            drag['sx'], drag['sy'] = pos['x'], pos['y']
            GtkLayerShell.set_margin(win, GtkLayerShell.Edge.LEFT, 0)
            GtkLayerShell.set_margin(win, GtkLayerShell.Edge.TOP, 0)
            GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.RIGHT, True)
            GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.BOTTOM, True)
            root.move(frame, pos['x'], pos['y'])

        def h_motion(w_, ev):
            if not drag['on']:
                return
            dlog('motion', ev)
            if drag['ox'] is None:
                drag['ox'], drag['oy'] = ev.x_root, ev.y_root
                return
            fa = frame.get_allocation()
            nx = drag['sx'] + int(ev.x_root - drag['ox'])
            ny = drag['sy'] + int(ev.y_root - drag['oy'])
            pos['x'] = max(0, min(nx, out['w'] - fa.width))
            pos['y'] = max(0, min(ny, out['h'] - fa.height))
            root.move(frame, pos['x'], pos['y'])

        def h_release(w_, ev):
            if not drag['on']:
                return
            dlog('release', ev)
            drag['on'] = False
            GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.RIGHT, False)
            GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.BOTTOM, False)
            root.move(frame, 0, 0)
            # clamp against the frame, not the window — the window is
            # still monitor-sized until the un-anchor takes effect
            place(frame.get_allocation())

        hbar.connect('button-press-event', h_press)
        hbar.connect('motion-notify-event', h_motion)
        hbar.connect('button-release-event', h_release)
        win.ts_drag = (h_press, h_motion, h_release, pos)  # test hook
        box.pack_start(hbar, False, False, 0)

        label = Gtk.Label(label='Translating…')
        state = {'done': False, 'secs': 0}

        def tick():
            if state['done']:
                return False
            state['secs'] += 1
            if state['secs'] >= 3:
                label.set_text('Translating… (%ds)' % state['secs'])
            return True

        GLib.timeout_add_seconds(1, tick)
        label.set_selectable(True)
        label.set_line_wrap(True)
        label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
        label.set_xalign(0)
        label.set_max_width_chars(60)
        scroller = Gtk.ScrolledWindow()
        scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
        scroller.set_propagate_natural_height(True)
        scroller.set_propagate_natural_width(True)
        scroller.set_max_content_height(max(160, min(520, out['h'] - 90)))
        scroller.add(label)
        box.pack_start(scroller, True, True, 0)
        frame.add(box)
        # frame sits in a Fixed so it can travel inside the surface
        # while dragging (see h_press)
        root = Gtk.Fixed()
        root.put(frame, 0, 0)
        win.add(root)

        # glass look: follow the system GTK theme (colors + font) with a
        # 25%-translucent backdrop; needs the RGBA visual set below
        win.get_style_context().add_class('ts-popup')
        css = Gtk.CssProvider()
        css.load_from_data(b'''
            window.ts-popup, window.ts-popup decoration {
                background: transparent; }
            window.ts-popup frame {
                background-color: alpha(@theme_bg_color, 0.75);
                border: 1px solid alpha(@theme_fg_color, 0.3);
                border-radius: 10px; }
            window.ts-popup label { color: @theme_fg_color; }
            window.ts-popup .dim-label {
                color: alpha(@theme_fg_color, 0.55); }
            window.ts-popup button {
                color: @theme_fg_color; font-size: 14pt; padding: 0 4px;
                min-height: 0; min-width: 0; background: none;
                border: none; box-shadow: none; }
        ''')
        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(), css,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

        rgba = win.get_screen().get_rgba_visual()
        if rgba is not None:
            win.set_visual(rgba)

        def key(w_, ev):
            if ev.keyval == Gdk.KEY_Escape:
                Gtk.main_quit()

        win.connect('key-press-event', key)
        win.show_all()

        def worker():
            try:
                text = translate(img_path, load_config())
            except subprocess.TimeoutExpired:
                text = ('Translation timed out.\n'
                        'Try again; the first run of the claude CLI can be slow.')
            except Exception as e:  # noqa: BLE001
                text = 'Error: %s' % e
            finally:
                try:
                    os.unlink(img_path)
                except OSError:
                    pass
            state['done'] = True

            def show_result():
                label.set_text(text)
                # a wrapping label under-reports natural height inside a
                # ScrolledWindow; measure with Pango and size explicitly
                lay = label.create_pango_layout(text)
                lay.set_width(560 * Pango.SCALE)
                lay.set_wrap(Pango.WrapMode.WORD_CHAR)
                pw, ph = lay.get_pixel_size()
                cap = max(160, min(520, out['h'] - 90))
                scroller.set_min_content_height(min(cap, ph + 16))
                scroller.set_min_content_width(min(580, max(pw + 8, 220)))
                longest = max((len(l) for l in text.splitlines()),
                              default=10)
                label.set_width_chars(min(60, max(longest, 10)))
                return False

            GLib.idle_add(show_result)
            # also copy to clipboard for convenience
            def to_clip():
                clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
                clip.set_text(text, -1)
                return False
            GLib.idle_add(to_clip)

        threading.Thread(target=worker, daemon=True).start()


# ------------------------------------------------------------------- main ---

def cmd_check():
    cfg = load_config()
    engine = choose_engine(cfg)
    print('%s %s' % (PROG, VERSION))
    print('config file : %s%s' % (CONFIG_FILE, '' if os.path.exists(CONFIG_FILE)
                                  else ' (not present)'))
    print('engine      : %s' % (engine or 'NONE AVAILABLE'))
    print('model       : %s' % cfg['TRANSLATE_SHOT_MODEL'])
    print('language    : %s' % cfg['TRANSLATE_SHOT_LANG'])
    print('api key     : %s' % ('set' if cfg['ANTHROPIC_API_KEY'] else 'not set'))
    print('claude CLI  : %s' % (find_claude(cfg) or 'not found'))
    return 0 if engine else 1


def main(argv=None):
    argv = sys.argv[1:] if argv is None else argv
    if argv[:1] in (['-h'], ['--help']):
        print(__doc__.strip())
        return 0
    if argv[:1] == ['--version']:
        print(VERSION)
        return 0
    if argv[:1] == ['--check']:
        return cmd_check()
    if argv[:1] == ['--file'] and len(argv) == 2:
        print(translate(os.path.abspath(argv[1]), load_config()))
        return 0
    if argv:
        sys.stderr.write('usage: %s [--file IMAGE | --check | --version]\n'
                         % PROG)
        return 2
    load_gui()
    import fcntl
    lock = open(os.path.join(runtime_dir(), PROG + '.lock'), 'w')
    try:
        fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except OSError:
        return 0  # already running
    App().run()
    return 0


if __name__ == '__main__':
    sys.exit(main())
