"""Paste text at cursor: copy to clipboard, send Cmd+V via AppleScript, optionally restore clipboard.

Hardening (2026-05-26 — second iteration after user diagnosed the real bug):

  1. TTL-based per-target blacklist. After PASTE_FAILURE_THRESHOLD consecutive
     osascript failures into the same bundle (Chrome in a Sequoia state where
     System Events Accessibility is unresponsive), blacklist that target for
     PASTE_BLACKLIST_TTL_SECONDS. While blacklisted, paste_text() short-circuits
     to clipboard-only + notify BEFORE acquiring any lock — so a wedged Chrome
     never delays a Codex paste.

  2. Pre-lock blacklist check. The clipboard write + osascript invocation
     still need a global mutex (two concurrent osascripts racing on the SAME
     clipboard would paste each other's text into the wrong target — observed
     in the wild). But the blacklist check happens BEFORE the lock, so
     blacklisted targets fail-fast without queueing behind a slow paste.

  3. Skip the bundle-id retry when the named path already timed out — same
     reasoning as before: a timeout means System Events is wedged for this
     target, picking a different identifier won't unwedge it.

  4. Shorter per-attempt timeout (1.5 s, down from 2.0 s). Cuts the worst-case
     'first 3 Chrome failures' window from 6 s to 4.5 s, after which the
     blacklist kicks in and everything is instant.

User report that motivated v2:
'когда я из хрома переключаюсь в какое-нибудь другое приложение и пытаюсь
диктовать там, у меня зависает сразу наше приложение.'

The hang in v1 was the global lock being held by an in-flight Chrome paste
(timing out), blocking the next dictation's paste into Codex. v2's pre-lock
blacklist check fixes that path; once Chrome is blacklisted, Codex pastes
fire immediately.
"""

from __future__ import annotations

import subprocess
import threading
import time

from AppKit import NSPasteboard, NSPasteboardTypeString  # type: ignore


# ── Tuning knobs ────────────────────────────────────────────────────────

# After this many CONSECUTIVE failures into the same bundle, blacklist that
# target for PASTE_BLACKLIST_TTL_SECONDS. While blacklisted, paste_text()
# skips osascript entirely and falls back to clipboard + notification.
PASTE_FAILURE_THRESHOLD = 3

# How long a target stays blacklisted after crossing the failure threshold.
# After this expires, the next paste into the target tries osascript again —
# Chrome might have recovered from its System Events wedge by then.
PASTE_BLACKLIST_TTL_SECONDS = 60.0

# Per-attempt osascript timeout. 1.5s is empirically enough for a healthy
# target to activate + receive the keystroke, and tight enough that 3 failures
# only burn 4.5 s before the blacklist kicks in (down from 6 s at 2.0 s).
PASTE_OSASCRIPT_TIMEOUT = 1.5

# How long to give the target to consume the clipboard before we restore the
# previous clipboard content. Too short and the target reads stale clipboard;
# too long and the user notices a flicker. 0.5s is the sweet spot.
CLIPBOARD_RESTORE_DELAY = 0.50


# ── State (process-wide) ────────────────────────────────────────────────

# Mutex serialising clipboard write + osascript invocation. Without this,
# two concurrent _pbcopy calls clobber each other and osascript ends up
# pasting the wrong text into the wrong target. Held only while a paste
# is genuinely in flight — blacklisted targets short-circuit before
# acquiring this lock so a wedged Chrome can't block a healthy Codex paste.
_paste_lock = threading.Lock()

# bundle_id (or name fallback) → number of consecutive failures.
# Resets to 0 on the next success into that target, or when the blacklist
# TTL expires.
_consecutive_failures: dict[str, int] = {}

# bundle_id (or name fallback) → epoch seconds at which the blacklist
# expires for that target. Checked at the top of paste_text() before any
# lock acquisition.
_blacklist_until: dict[str, float] = {}


def _pbcopy(text: str) -> None:
    # Use native NSPasteboard instead of `pbcopy` subprocess — when launched from a
    # .app bundle (no terminal LANG inherited), pbcopy interpreted UTF-8 bytes as
    # MacRoman → cyrillic became mojibake (–ù—É —á—Ç–æ). NSPasteboard handles
    # Unicode strings directly with no encoding dance.
    pb = NSPasteboard.generalPasteboard()
    pb.clearContents()
    pb.setString_forType_(text, NSPasteboardTypeString)


def _pbpaste() -> str:
    pb = NSPasteboard.generalPasteboard()
    s = pb.stringForType_(NSPasteboardTypeString)
    return str(s) if s else ""


def _notify_clipboard_fallback(target_name: str | None) -> None:
    """Post a macOS user notification when we stop retrying paste into a
    sticky target. Without this, the user thinks Lirelle silently failed; with
    it, they know the text is on the clipboard and a Cmd+V will land it.
    Best-effort: notification framework can refuse (TCC, focus mode), so
    we just log and move on."""
    where = target_name or "the focused app"
    try:
        import rumps
        rumps.notification(
            "Lirelle",
            "Скопировано в буфер обмена",
            f"{where} не отвечает на автоматическую вставку. Нажми Cmd+V вручную.",
        )
    except Exception as e:
        print(f"[paster] notification post failed: {e}")


def _cmd_v() -> None:
    # `key code 9` = physical V key, layout-independent.
    # `keystroke "v"` would send the character "v", which on Russian layout maps to "м"
    # (Cmd+М is not paste, so nothing happens). key code dodges that entirely.
    # `timeout=2.0` matches _activate_and_paste — without it, a hung System Events
    # (frontmost app in modal sheet, TCC prompt blocking, Sequoia event-routing
    # bug) leaves the paste thread alive forever, leaking transcript memory.
    try:
        subprocess.run(
            ["osascript", "-e",
             'tell application "System Events" to key code 9 using command down'],
            timeout=PASTE_OSASCRIPT_TIMEOUT,
            check=False,
        )
    except subprocess.TimeoutExpired:
        print("[paster] _cmd_v: osascript timed out — text is still on the clipboard, "
              "user can Cmd+V manually")


def _is_blacklisted(target_id: str) -> bool:
    """True if `target_id` was recently blacklisted and the TTL hasn't
    expired. Side-effect: when the TTL expires, clears the entry so the
    next paste attempt gets a fresh shot at the target."""
    expiry = _blacklist_until.get(target_id, 0.0)
    if expiry == 0.0:
        return False
    if time.time() >= expiry:
        # TTL expired — clear state and let the next paste try again. Chrome
        # may have unwedged its System Events connection by now.
        # pop() instead of del so two concurrent expiry checks don't race
        # into a KeyError on the second `del`.
        _blacklist_until.pop(target_id, None)
        _consecutive_failures.pop(target_id, None)
        return False
    return True


def _as_applescript_literal(s: str) -> str:
    """Escape an arbitrary string for use as an AppleScript string literal.

    Audit #1 (2026-06-10). The frontmost app's `localizedName` and
    `bundleIdentifier` are reflected from CFBundleDisplayName / Info.plist,
    which a malicious sibling app can craft to contain `"` and newlines.
    If we interpolated these raw into an osascript source string, a
    crafted name like `Mail" to activate\\ndo shell script "..."` would
    close out our string and inject a `do shell script`. Hard-escape:
    backslash first, then quotes; strip CR/LF (no legitimate use case
    for a multi-line bundle name)."""
    s = (s or "").replace("\\", "\\\\").replace('"', '\\"')
    s = s.replace("\n", "").replace("\r", "")
    return f'"{s}"'


def _activate_and_paste(name: str | None, bundle_id: str | None,
                        pid: int | None = None) -> bool:
    """One atomic osascript: activate target → delay → send Cmd+V targeted at that
    process via System Events. Much more reliable in Sonoma+ than separate
    activate + system-wide keystroke, because the keystroke is delivered to the
    named process even if focus shifts slightly during the delay.

    Targeting precedence: PID > name > bundle. The PID path is the only one
    that reliably distinguishes between two processes sharing a name + bundle
    id (e.g. user's normal Chrome + a debug-port Chrome — both are
    'Google Chrome' with bundle 'com.google.Chrome'). When PID is provided,
    we tell System Events `tell process id N` which targets that exact
    unix process. If the PID disappeared (target quit between capture
    and paste), we fall through to name/bundle.

    Returns True iff a paste actually succeeded. The TTL blacklist check
    lives in paste_text() above the lock, so by the time we reach here we
    know the target is not currently blacklisted."""
    # CR #2 — the blacklist key must match the one paste_text uses on the
    # pre-lock fast-path (`target_bundle_id or target_name`). Previously
    # we keyed off `pid:N` whenever a PID was available; PIDs change per
    # app launch anyway, and the read side never saw `pid:` keys — so the
    # wedged-Chrome short-circuit never fired and pastes piled up behind
    # the slow target. Stick to bundle/name so the read and write agree.
    failure_key = bundle_id or name or "<unknown>"

    attempts: list[tuple[str, str, str]] = []
    if pid is not None:
        # `process id N` is the System Events way to address by unix pid.
        # The activation path uses NSRunningApplication via osascript's
        # `tell application "System Events" to ...` machinery.
        attempts.append(("pid", str(pid), f"id {pid}"))
    if name:
        attempts.append(("name", name, _as_applescript_literal(name)))
    if bundle_id:
        attempts.append(("bundle", bundle_id,
                         f'id {_as_applescript_literal(bundle_id)}'))

    last_was_timeout = False
    for label, identifier, target_expr in attempts:
        # If the prior attempt already timed out, the bundle-id retry won't
        # help — the timeout means System Events is wedged for this process,
        # not that we picked the wrong identifier. Skip the second attempt.
        if last_was_timeout:
            print(f"[paster] skipping {label}({identifier}) — previous attempt "
                  f"timed out, System Events is wedged for this target")
            break

        if label == "pid":
            # Address THIS specific unix process. Activates via the
            # bundle path that NSRunningApplication exposes to AppleScript
            # (the process is grabbed by its unix id, then `set frontmost
            # to true` brings its windows up), and the keystroke is sent
            # to that same process so it can't land in a sibling.
            script = (
                f'tell application "System Events"\n'
                f'  set targetProc to first process whose unix id is {pid}\n'
                f'  set frontmost of targetProc to true\n'
                f'  delay 0.18\n'
                f'  tell targetProc to key code 9 using command down\n'
                f'end tell\n'
            )
        elif label == "name":
            # System Events process names match localizedName — atomically activate +
            # tell that exact process to receive the keystroke.
            # Use `key code 9` (physical V) instead of `keystroke "v"` so it works
            # regardless of current keyboard layout (на русской раскладке "v" → "м").
            script = (
                f'tell application {target_expr} to activate\n'
                f'delay 0.18\n'
                f'tell application "System Events"\n'
                f'  tell process {target_expr}\n'
                f'    key code 9 using command down\n'
                f'  end tell\n'
                f'end tell\n'
            )
        else:
            # only bundle id — activate, then system-wide keystroke (less targeted)
            script = (
                f'tell application {target_expr} to activate\n'
                f'delay 0.25\n'
                f'tell application "System Events" to key code 9 using command down\n'
            )
        try:
            r = subprocess.run(
                ["osascript", "-e", script],
                capture_output=True, timeout=PASTE_OSASCRIPT_TIMEOUT, check=False,
            )
            if r.returncode == 0:
                print(f"[paster] activate+paste via {label}({identifier}): ok")
                _consecutive_failures[failure_key] = 0
                return True
            err = r.stderr.decode("utf-8", errors="replace").strip()
            print(f"[paster] activate+paste via {label}({identifier}) failed: {err}")
        except subprocess.TimeoutExpired:
            print(f"[paster] activate+paste via {label}({identifier}) timed out "
                  f"after {PASTE_OSASCRIPT_TIMEOUT}s — System Events not responding")
            last_was_timeout = True
        except Exception as e:
            print(f"[paster] activate+paste via {label}({identifier}) exception: {e}")

    # No path succeeded — bump failure counter. If we just crossed the
    # threshold, set the blacklist expiry so subsequent paste_text() calls
    # short-circuit to clipboard-only BEFORE acquiring _paste_lock (which is
    # the path that was causing 'Codex paste hangs while Chrome paste is
    # mid-timeout' in v1).
    _consecutive_failures[failure_key] = _consecutive_failures.get(failure_key, 0) + 1
    if _consecutive_failures[failure_key] >= PASTE_FAILURE_THRESHOLD:
        _blacklist_until[failure_key] = time.time() + PASTE_BLACKLIST_TTL_SECONDS
        print(f"[paster] {failure_key} hit {PASTE_FAILURE_THRESHOLD} consecutive "
              f"failures — blacklisting for {int(PASTE_BLACKLIST_TTL_SECONDS)}s, "
              f"clipboard-only fallback")
        _notify_clipboard_fallback(name)
    return False


def paste_text(text: str, restore_clipboard: bool = True,
               target_bundle_id: str | None = None,
               target_name: str | None = None,
               target_pid: int | None = None) -> None:
    """Copy text to clipboard → atomic activate+paste into target (if known) or
    system-wide Cmd+V (no target) → restore clipboard.

    If target_* are None, this is a "paste into whatever is focused now" call —
    used by the manual repaste hotkey (Cmd+Opt+V).

    Pre-lock blacklist check: if the target has been failing recently, we
    write the text to the clipboard, notify the user to hit Cmd+V manually,
    and return WITHOUT acquiring _paste_lock. This is what fixes the
    'switch from Chrome to Codex, Lirelle hangs' bug: a wedged Chrome no longer
    holds the lock while Codex pastes pile up behind it.

    Non-blacklisted targets serialise through `_paste_lock` so two concurrent
    pastes don't clobber each other's clipboard state.
    """
    if not text:
        return

    target_id = target_bundle_id or target_name or "<system>"
    if target_id != "<system>" and _is_blacklisted(target_id):
        # Pre-lock fast path: copy text to clipboard so user can Cmd+V it
        # manually, post a notification, return immediately. We still take a
        # very brief slice of _paste_lock around _pbcopy to avoid clobbering
        # an in-flight paste's clipboard write into a DIFFERENT target.
        print(f"[paster] {target_id} blacklisted — skipping osascript, "
              f"clipboard-only (Cmd+V manually)")
        with _paste_lock:
            _pbcopy(text)
        _notify_clipboard_fallback(target_name)
        return

    with _paste_lock:
        prev = _pbpaste() if restore_clipboard else None
        _pbcopy(text)
        time.sleep(0.05)
        if target_pid is not None or target_name or target_bundle_id:
            _activate_and_paste(target_name, target_bundle_id,
                                pid=target_pid)
        else:
            _cmd_v()
        if restore_clipboard and prev is not None:
            time.sleep(CLIPBOARD_RESTORE_DELAY)
            _pbcopy(prev)
