"""
Entry point for terminull on macOS (menu bar mode).

Startup sequence:
  1. Auto-install LaunchAgent (first run only)
  2. Start asyncio BridgeApp (HeadlessApp subclass) in a daemon thread
  3. Run menu bar app on main thread (macOS requires AppKit on main thread)

BridgeApp extends HeadlessApp with:
  - Menu bar status updates (relay, adapters, sessions, updates)
  - TCC (Accessibility) permission check on startup
  - Agent refresh loop + background update check loop
"""
from __future__ import annotations
import asyncio
import logging
import os
import queue
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path

from dotenv import load_dotenv
load_dotenv()

from terminull_bridge.headless import HeadlessApp, RELAY_URL, API_URL
from terminull_bridge.auth import KeychainAccessError
from terminull_bridge.detector import detect_all
from terminull_bridge import config as cfg
from . import launchagent
from . import hermes_gateway
from .menubar import (
    make_status_queue, run_menubar,
    RelayStatus, AdapterStatus, QRData, UpdateAvailable, SessionCount, AuthExpired,
    WhisperStatus, SummarizerStatus,
)

log = logging.getLogger(__name__)

# Min seconds between network-path-triggered relay reconnects (see _install_network_monitor):
# a burst of interface changes during a Wi-Fi/VPN transition must not repeatedly drop a
# just-established socket.
_NET_KICK_DEBOUNCE_S = 3.0


def _get_machine_name() -> str:
    if sys.platform == "darwin":
        try:
            out = subprocess.run(
                ["system_profiler", "SPHardwareDataType"],
                capture_output=True, text=True, timeout=5,
            ).stdout
            for line in out.splitlines():
                if "Model Name" in line:
                    return line.split(":", 1)[1].strip()
        except Exception:
            pass
    return socket.gethostname()


class BridgeApp(HeadlessApp):
    def __init__(self, status_queue: queue.Queue) -> None:
        super().__init__()
        self._sq = status_queue
        # A staged (downloaded + Gatekeeper-verified) .app bundle awaiting the idle swap.
        self._staged_app: Path | None = None
        self._staged_root: Path | None = None

    # ── HeadlessApp extension points ──────────────────────────────────────────

    def _on_relay_status(self, connected: bool) -> None:
        # Run the base handler first — on a disconnect it resets the relay-presence
        # bookkeeping (_relay_presence_known / _relay_ios_count) so resource eviction +
        # the connection count don't stay stale until the idle reaper. Then update the menu.
        super()._on_relay_status(connected)
        self._sq.put(RelayStatus(connected=connected))
        log.info("Relay %s", "connected" if connected else "disconnected")

    def _extra_relay_kwargs(self) -> dict:
        return {
            "machine_name": _get_machine_name(),
        }

    def _extra_tasks(self) -> list:
        # _update_check_loop now lives in HeadlessApp (base), routed through
        # _on_update_available below — so the Mac app no longer adds its own.
        return super()._extra_tasks() + [self._agent_refresh_loop(), self._status_poll_loop()]

    async def _on_update_available(self, version: str, url: str, mac_url: str = "") -> None:
        # Surface it in the menu bar (informational + a manual "update now" that bypasses
        # the idle wait); the base class also prepares it and applies automatically once the
        # bridge is idle. Only (re)enqueue the menu item the first time we see this version.
        if self._pending_update_version != version:
            self._sq.put(UpdateAvailable(version=version, url=url, mac_url=mac_url))
        await super()._on_update_available(version, url, mac_url)

    async def _prepare_update(self, version: str, url: str, mac_url: str = "") -> bool:
        """Mac .app build: download + Gatekeeper-verify a staged bundle WITHOUT touching the
        running app; the swap + restart happen later in `_apply_update` once idle. A dev Mac
        app execs the venv python (build_kind == 'venv'), so it falls back to the base
        pip/editable path (which the editable guard skips)."""
        from terminull_bridge.updater import build_kind, stage_app_update
        if build_kind() != "app":
            return await super()._prepare_update(version, url, mac_url)
        if not mac_url:
            return False
        try:
            new_app, staging = await asyncio.get_running_loop().run_in_executor(
                None, stage_app_update, mac_url)
        except Exception as e:
            log.error("Auto-update stage (app) of v%s failed: %s", version, e)
            return False
        self._staged_app, self._staged_root = new_app, staging
        return True

    def _apply_update(self) -> None:
        """Swap the staged bundle into place then restart the LaunchAgent. Falls back to the
        base restart when there's no staged bundle (venv Mac)."""
        from terminull_bridge.updater import apply_app_update
        if self._staged_app is None:
            return super()._apply_update()
        import shutil
        try:
            apply_app_update(self._staged_app)
        finally:
            if self._staged_root is not None:
                shutil.rmtree(self._staged_root, ignore_errors=True)
            self._staged_app = self._staged_root = None
        launchagent.restart()

    async def _after_agents_detected(self) -> None:
        for agent in self._agents:
            self._sq.put(AdapterStatus(agent_id=agent.id, name=agent.name, state="ready"))

    def _build_pair_url(self) -> str:
        return (
            f"terminull://pair"
            f"?id={self._auth.beacon_id}"
            f"&pk={self._auth.pubkey_b64}"
            f"&epk={self._auth.epk_b64}"
            f"&relay={RELAY_URL}"
            f"&claim={self._auth.get_claim_code()}"
        )

    async def _poll_for_auth_token(self) -> None:
        # First run is unpaired: the base only prints the ASCII QR to the log, which
        # is invisible in a menu-bar app (and "Show QR Code" would say "No QR yet"
        # because QRData isn't pushed until after pairing). Proactively surface the
        # pairing QR + a welcome nudge so a fresh user knows to scan.
        self._sq.put(QRData(pair_url=self._build_pair_url(), auto_show=True))
        await super()._poll_for_auth_token()

    async def _after_paired(self) -> None:
        self._sq.put(QRData(pair_url=self._build_pair_url()))

    async def _before_relay(self) -> None:
        # Vision (camera / photo attach) is always available on Mac — set once on first launch.
        if "vision_enabled" not in cfg.load().get("features", {}):
            cfg.set_feature("vision_enabled", True)

    def _on_auth_error(self) -> None:
        super()._on_auth_error()
        self._sq.put(AuthExpired())

    def _sessions_changed(self) -> None:
        # Fires on every session add/remove (start, end, kick, idle-reap) AND on client
        # connect/disconnect (via _connections_changed) — keeps the menu-bar counts
        # accurate. `connections` = distinct live client devices (not sessions).
        super()._sessions_changed()   # also re-pushes the unified live-session list
        self._sq.put(SessionCount(active=len(self._sessions),
                                  connections=self._connection_count()))

    # ── Background loops (mac-only) ───────────────────────────────────────────

    _AGENT_REFRESH_INTERVAL = 300

    async def _agent_refresh_loop(self) -> None:
        while True:
            await asyncio.sleep(self._AGENT_REFRESH_INTERVAL)
            try:
                fresh = detect_all()
                old_ids = {a.id for a in self._agents}
                new_ids = {a.id for a in fresh}
                if old_ids == new_ids:
                    continue
                added = new_ids - old_ids
                removed = old_ids - new_ids
                if added:
                    log.info("New agents detected: %s", ", ".join(added))
                if removed:
                    log.info("Agents no longer available: %s", ", ".join(removed))
                self._agents = fresh
                for agent in fresh:
                    self._sq.put(AdapterStatus(agent_id=agent.id, name=agent.name, state="ready"))
                agent_list = [{"id": a.id, "name": a.name, "tools": len(a.mcp_tools)} for a in fresh]
                if self._relay:
                    await self._relay.update_agents(agent_list)
            except Exception as e:
                log.warning("Agent refresh failed: %s", e)

    _STATUS_POLL_INTERVAL = 30

    async def _status_poll_loop(self) -> None:
        from terminull_bridge.stt_session import is_model_ready as _stt_model_ready
        from terminull_bridge import summarizer, mlx_llm, load_status

        # Push the menu bar the moment a load phase changes — the 30 s poll below is
        # far too coarse to show a transient cold load/download (the "looks like a
        # hang" gap). Fires from the STT executor thread or the LLM-proxy loop
        # thread; `queue.Queue.put` is thread-safe.
        def _on_load_change(key: str, state: str, pct: float) -> None:
            try:
                if key == "stt":
                    self._sq.put(WhisperStatus(loaded=_stt_model_ready(), state=state, pct=pct))
                elif key == "llm":
                    model = summarizer.model_name()
                    self._sq.put(SummarizerStatus(enabled=model != "none",
                                                  model_loaded=mlx_llm.is_loaded(),
                                                  model_name=model, state=state, pct=pct))
            except Exception:
                pass
        load_status.subscribe(_on_load_change)

        await asyncio.sleep(5)
        while True:
            try:
                stt_state, stt_pct = load_status.get_state("stt")
                self._sq.put(WhisperStatus(loaded=_stt_model_ready(), state=stt_state, pct=stt_pct))

                llm_state, llm_pct = load_status.get_state("llm")
                model = summarizer.model_name()
                enabled = model != "none"
                loaded = await summarizer.is_loaded() if enabled else False
                self._sq.put(SummarizerStatus(enabled=enabled, model_loaded=loaded,
                                              model_name=model, state=llm_state, pct=llm_pct))
            except Exception as e:
                log.debug("Status poll error: %s", e)
            await asyncio.sleep(self._STATUS_POLL_INTERVAL)


# ── Runner ────────────────────────────────────────────────────────────────────

def _alert_keychain_denied() -> None:
    """Bridge identity couldn't be read from the Keychain (user denied, or it's locked). Show a
    clear main-thread alert with a Relaunch/Quit choice, then exit — never continue, since a
    denied read must NOT regenerate identity (auth._load_identity_store raises rather than let
    that happen). rumps.alert must run on the main thread, so hop there via AppHelper."""
    def _show() -> None:
        import rumps
        res = rumps.alert(
            title="Terminull needs Keychain access",
            message=(
                "Terminull keeps its secure identity in your macOS Keychain, but access was "
                "denied so the bridge can't start.\n\n"
                "Relaunch and choose “Always Allow” when macOS asks, so you won't be prompted "
                "again."
            ),
            ok="Relaunch",
            cancel="Quit",
        )
        if res == 1:  # Relaunch: exit first to release the singleton lock, then reopen.
            subprocess.Popen(["/bin/sh", "-c", f"sleep 1; open -n '{INSTALL_APP}'"],
                             start_new_session=True)
        os._exit(0)
    try:
        from PyObjCTools import AppHelper
        AppHelper.callAfter(_show)
    except Exception as e:
        log.error("keychain-denied alert could not be shown: %s", e)
        os._exit(1)


def _run_bridge(status_queue: queue.Queue, holder: dict) -> None:
    # Manage the loop by hand (instead of asyncio.run) so the main thread can reach BOTH the
    # BridgeApp and its loop at Quit time — the menu-bar shutdown callback schedules the bridge's
    # adapter-teardown coroutine onto this loop from the main thread (run_coroutine_threadsafe).
    #
    # BeaconAuth (built in BridgeApp.__init__) reads the Keychain. A Deny/lock raises
    # KeychainAccessError — retry ONCE (a fat-finger Deny or a transient lock re-prompts and may
    # succeed), then surface the alert rather than starting identity-less or regenerating keys.
    app = None
    for attempt in (1, 2):
        try:
            app = BridgeApp(status_queue)
            break
        except KeychainAccessError as e:
            log.error("Keychain access failed loading bridge identity (attempt %d/2): %s", attempt, e)
            if attempt == 1:
                time.sleep(1.0)
                continue
            _alert_keychain_denied()  # shows alert on main thread + exits; never returns here
            return
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    holder["app"] = app
    holder["loop"] = loop
    try:
        loop.run_until_complete(app.run())
    finally:
        loop.close()


from logging.handlers import TimedRotatingFileHandler


def setup_logging() -> None:
    """Configure logging with daily rotation and old log cleanup."""
    log_dir = Path.home() / "Library" / "Logs" / "Terminull"
    log_dir.mkdir(parents=True, exist_ok=True)
    
    # Use TimedRotatingFileHandler for daily rotation
    # Keeps last 7 days, rotates at midnight
    handler = TimedRotatingFileHandler(
        filename=log_dir / "bridge.log",
        when="midnight",
        interval=1,
        backupCount=7,
        encoding="utf-8",
    )
    handler.suffix = "%Y-%m-%d"
    handler.setFormatter(logging.Formatter(
        "%(asctime)s %(levelname)s %(name)s: %(message)s"
    ))
    
    logging.basicConfig(
        level=logging.INFO,
        handlers=[handler],
    )
    
    # Curated "signalling" log by default: connection/transport/turn/session INFO is kept,
    # the high-frequency adapter raw-line + per-event chatter is demoted to DEBUG at its
    # call sites. Set TERMINULL_LOG_VERBOSE=1 to restore full DEBUG for deep debugging.
    verbose = os.environ.get("TERMINULL_LOG_VERBOSE") == "1"
    detail_level = logging.DEBUG if verbose else logging.INFO
    logging.getLogger("terminull_bridge.sentinel_tools").setLevel(detail_level)
    logging.getLogger("terminull_bridge.headless").setLevel(detail_level)
    # Suppress benign websockets handshake-probe ERROR spam (port scanners / health checks /
    # carrier middlebox probes) — the headless path already does this; the Mac path didn't.
    for _noisy in ("websockets.server", "websockets.asyncio.server"):
        logging.getLogger(_noisy).setLevel(logging.CRITICAL)

    # Clean up old log files (older than 7 days)
    _cleanup_old_logs(log_dir)


def _cleanup_old_logs(log_dir: Path) -> None:
    """Delete rotated log files older than 7 days, and truncate bridge.err if oversized."""
    cutoff = time.time() - (7 * 24 * 60 * 60)  # 7 days ago
    
    # Clean up old rotated logs (bridge.log.2025-06-20, etc.)
    for f in log_dir.iterdir():
        if not f.is_file():
            continue
        # Skip current log files and adapter directory
        if f.name in ("bridge.log", "bridge.err", "monitor.log", "monitor.err", "monitor.out", "adapters"):
            continue
        try:
            if f.stat().st_mtime < cutoff:
                f.unlink()
                logging.getLogger(__name__).debug("Deleted old log: %s", f.name)
        except Exception:
            pass
    
    # Clean up adapter subdirectories
    adapters_dir = log_dir / "adapters"
    if adapters_dir.exists():
        for adapter_dir in adapters_dir.iterdir():
            if not adapter_dir.is_dir():
                continue
            for f in adapter_dir.iterdir():
                if not f.is_file():
                    continue
                try:
                    if f.stat().st_mtime < cutoff:
                        f.unlink()
                        logging.getLogger(__name__).debug("Deleted old adapter log: %s/%s", adapter_dir.name, f.name)
                except Exception:
                    pass
    
    # Cap every active log file at _LOG_CAP_BYTES (keep the recent tail). Covers the
    # launchd-append files that have no rotation handler of their own — bridge.err,
    # bridge.out, and especially hermes-gateway.log (a launchd stdout redirect that
    # otherwise grows unbounded; it hit 146 MB on 2026-07-13). bridge.log itself is
    # bounded by its TimedRotatingFileHandler, but is included as a belt-and-suspenders
    # cap for a single runaway day. Safe on the live launchd files: they are opened
    # O_APPEND, so a truncate-and-rewrite of the tail leaves the writer appending at the
    # new EOF (no sparse hole).
    _truncate_oversized_logs(log_dir)

    # Also clean up old adapter logs
    adapters_dir = log_dir / "adapters"
    if adapters_dir.exists():
        for f in adapters_dir.iterdir():
            if not f.is_file():
                continue
            try:
                if f.stat().st_mtime < cutoff:
                    f.unlink()
            except Exception:
                pass


# Cap for the daily log truncation. Files above this are trimmed to their last
# _LOG_CAP_BYTES (snapped to a line boundary). User-requested 10 MB ceiling.
_LOG_CAP_BYTES = 10 * 1024 * 1024


def _truncate_oversized_logs(log_dir: Path) -> None:
    """Trim any active .log/.err/.out file above _LOG_CAP_BYTES down to its last
    _LOG_CAP_BYTES, preserving the recent tail (from the first newline in the kept
    window, so we don't start mid-line). Rotated backups (bridge.log.YYYY-MM-DD) are
    left to _cleanup_old_logs' age-based prune. Idempotent and failure-isolated per
    file so one unreadable file can't abort the sweep."""
    _log = logging.getLogger(__name__)
    for f in log_dir.glob("*"):
        if not f.is_file() or f.suffix not in (".log", ".err", ".out"):
            continue
        try:
            size = f.stat().st_size
            if size <= _LOG_CAP_BYTES:
                continue
            with open(f, "rb") as fh:
                fh.seek(-_LOG_CAP_BYTES, os.SEEK_END)
                tail = fh.read()
            nl = tail.find(b"\n")
            if 0 <= nl < len(tail) - 1:
                tail = tail[nl + 1:]        # start at a clean line boundary
            with open(f, "wb") as fh:        # truncate + rewrite (inode preserved)
                fh.write(tail)
            _log.info("Truncated %s (%.0f MB → %.0f MB)",
                      f.name, size / 1048576, len(tail) / 1048576)
        except Exception as e:
            _log.debug("log truncate skipped %s: %s", f.name, e)


def _start_daily_log_maintenance() -> None:
    """Run the log-dir cleanup + oversized-file truncation once a day (plus the
    startup pass in setup_logging). A daemon thread on a 24 h sleep loop — the bridge
    often runs for days, so a startup-only sweep never catches a file that balloons
    mid-run (the hermes-gateway.log case)."""
    log_dir = Path.home() / "Library" / "Logs" / "Terminull"

    def _loop() -> None:
        while True:
            time.sleep(24 * 60 * 60)
            try:
                _cleanup_old_logs(log_dir)
            except Exception as e:
                logging.getLogger(__name__).debug("daily log maintenance: %s", e)

    threading.Thread(target=_loop, daemon=True, name="terminull-log-maint").start()


_singleton_fd = None  # kept open for the process lifetime → holds the flock


def _acquire_singleton() -> bool:
    """Advisory single-instance lock. Returns True if we're the sole instance.

    launchd (RunAtLoad/KeepAlive) and a manual `open` of the bundle can both try to
    start Terminull; a second bridge binding the same ports (7849, …) breaks STT and
    relay. The flock is held by the winning process and auto-released by the kernel
    when it dies, so a genuinely-dead prior instance never blocks a relaunch. A loser
    exits **cleanly (0)** so the crash-only KeepAlive doesn't respawn it in a loop."""
    global _singleton_fd
    try:
        import fcntl
        lock_dir = Path.home() / ".terminull"
        lock_dir.mkdir(parents=True, exist_ok=True)
        fd = open(lock_dir / "instance.lock", "w")
        try:
            fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            fd.close()
            return False
        _singleton_fd = fd  # must outlive main()
        try:
            fd.write(str(os.getpid()))
            fd.flush()
        except Exception:
            pass
        return True
    except Exception as e:
        # Never let a lock-file hiccup prevent the app from starting.
        log.warning("single-instance guard unavailable (%s) — starting anyway", e)
        return True


def _untranslocated_path(path: Path) -> Path:
    """Return the real on-disk path for an App-Translocated bundle.

    When a *quarantined* app is launched from Downloads, macOS runs it from a
    read-only ``/private/var/folders/.../AppTranslocation/`` mount. ``ditto``
    refuses that source ("Cannot get the real path for source"), so self-install
    fails on the normal first-open path. Ask Security.framework for the original
    location so we can copy from the real bundle instead. No-op (returns the input
    unchanged) if the path isn't translocated, the API is unavailable, or
    resolution fails — callers must still guard against a translocated path.
    """
    s = str(path)
    if "/AppTranslocation/" not in s:
        return path
    try:
        import ctypes
        import ctypes.util

        security = ctypes.CDLL(ctypes.util.find_library("Security"))
        cf = ctypes.CDLL(ctypes.util.find_library("CoreFoundation"))
        if not hasattr(security, "SecTranslocateCreateOriginalPathForURL"):
            return path

        ref = ctypes.c_void_p
        cf.CFURLCreateFromFileSystemRepresentation.restype = ref
        cf.CFURLCreateFromFileSystemRepresentation.argtypes = [ref, ctypes.c_char_p, ctypes.c_long, ctypes.c_bool]
        cf.CFURLCopyFileSystemPath.restype = ref
        cf.CFURLCopyFileSystemPath.argtypes = [ref, ctypes.c_int]  # kCFURLPOSIXPathStyle = 0
        cf.CFStringGetCString.restype = ctypes.c_bool
        cf.CFStringGetCString.argtypes = [ref, ctypes.c_char_p, ctypes.c_long, ctypes.c_uint32]
        cf.CFRelease.argtypes = [ref]
        security.SecTranslocateCreateOriginalPathForURL.restype = ref
        security.SecTranslocateCreateOriginalPathForURL.argtypes = [ref, ref]  # (url, CFErrorRef*)

        buf = s.encode("utf-8")
        url = cf.CFURLCreateFromFileSystemRepresentation(None, buf, len(buf), True)
        if not url:
            return path
        try:
            orig = security.SecTranslocateCreateOriginalPathForURL(url, None)
        finally:
            cf.CFRelease(url)
        if not orig:
            return path
        try:
            cfstr = cf.CFURLCopyFileSystemPath(orig, 0)
        finally:
            cf.CFRelease(orig)
        if not cfstr:
            return path
        try:
            out = ctypes.create_string_buffer(4096)
            ok = cf.CFStringGetCString(cfstr, out, len(out), 0x08000100)  # kCFStringEncodingUTF8
        finally:
            cf.CFRelease(cfstr)
        if not ok:
            return path
        resolved = Path(out.value.decode("utf-8"))
        if resolved.exists():
            log.info("untranslocate: %s → %s", path, resolved)
            return resolved
        return path
    except Exception as e:
        log.warning("untranslocate: failed to resolve original path (%s)", e)
        return path


def _get_bundle_path() -> Path | None:
    """Return the .app bundle path if running from one, else None.

    De-translocates the result so a quarantined-Downloads launch yields the real
    on-disk bundle (see `_untranslocated_path`) rather than the read-only
    AppTranslocation mount that `ditto` can't copy from.
    """
    exe = Path(sys.executable).resolve()
    for anc in (exe, *exe.parents):
        if anc.suffix == ".app" and anc.is_dir():
            return _untranslocated_path(anc)
    return None


# Where the app expects to live for LaunchAgent to find it
INSTALL_DIR = Path.home() / ".terminull"
INSTALL_APP = INSTALL_DIR / "Terminull.app"


def _needs_self_install() -> bool:
    """True if we're not running from the installed location.

    Returns False if:
    - We're already at ~/.terminull/Terminull.app
    - We're in a dev venv (no .app bundle)
    """
    bundle = _get_bundle_path()
    if bundle is None:
        # Dev mode (venv), not a packaged app
        return False
    # Normalize paths for comparison
    try:
        return bundle.resolve() != INSTALL_APP.resolve()
    except Exception:
        return bundle != INSTALL_APP


def _self_install() -> bool:
    """Copy ourselves to ~/.terminull/Terminull.app.

    Returns True if we successfully installed and should restart.
    Returns False if already installed or installation failed.
    """
    import shutil

    bundle = _get_bundle_path()
    if bundle is None:
        log.warning("self_install: no bundle found, skipping")
        return False

    # If we're still running translocated (de-translocation failed), `ditto`
    # would fail on the read-only AppTranslocation mount. Skip WITHOUT touching
    # the existing install — keep running from the current copy. The install
    # succeeds on a later launch once the quarantine clears / the app is moved.
    if "/AppTranslocation/" in str(bundle):
        log.warning("self_install: running translocated and original path unresolved; skipping")
        return False

    staging = INSTALL_DIR / ".Terminull.app.incoming"
    backup = INSTALL_DIR / ".Terminull.app.old"
    try:
        INSTALL_DIR.mkdir(parents=True, exist_ok=True)
        shutil.rmtree(staging, ignore_errors=True)

        # Copy the bundle into a staging dir first (ditto preserves signatures,
        # xattrs). Only swap it in once the copy has fully succeeded, so a failed
        # copy can NEVER destroy a previously-working install (that once left
        # ~/.terminull/ empty and bricked the LaunchAgent target).
        log.info("self_install: copying %s → %s", bundle, staging)
        result = subprocess.run(
            ["ditto", str(bundle), str(staging)],
            capture_output=True, text=True, timeout=120
        )
        if result.returncode != 0:
            log.error("self_install: ditto failed: %s", result.stderr.strip())
            shutil.rmtree(staging, ignore_errors=True)
            return False

        # Atomic-ish swap on the same filesystem: move old aside, move new in,
        # then delete old. If we die mid-swap the backup is still recoverable.
        shutil.rmtree(backup, ignore_errors=True)
        if INSTALL_APP.exists():
            os.rename(INSTALL_APP, backup)
        os.rename(staging, INSTALL_APP)
        shutil.rmtree(backup, ignore_errors=True)

        log.info("self_install: successfully installed to %s", INSTALL_APP)
        return True
    except Exception as e:
        log.error("self_install: failed: %s", e)
        shutil.rmtree(staging, ignore_errors=True)
        return False


def _restart_from_install() -> None:
    """Open the installed app and exit this instance."""
    import shutil

    # Unload the LaunchAgent if it's pointing to the wrong location
    # (it will be reinstalled by the new instance with correct paths)
    if launchagent.is_installed():
        launchagent.stop()
        # Don't uninstall - let the new instance update the plist

    # Open the installed app
    log.info("self_install: launching installed app at %s", INSTALL_APP)
    subprocess.run(["open", str(INSTALL_APP)], check=False)

    # Exit this instance
    log.info("self_install: exiting initial instance")
    sys.exit(0)


def _prewarm_folder_access() -> None:
    """First-run-only: touch the three TCC-gated folders (Desktop/Documents/Downloads) so macOS
    surfaces their access prompts UPFRONT — once, together — rather than mid-browse the first
    time the folder picker lands in one. macOS gates ONLY these three (grants don't cascade from
    ~/; ordinary dirs like ~/code aren't gated), so this covers the whole prompt-able set (bar
    external volumes, which prompt on first touch). The `os.listdir` is what trips TCC → the
    system shows the prompt with our Info.plist NS*FolderUsageDescription message.

    Marker-gated + idempotent: after the user answers, macOS won't re-prompt, and the marker
    stops us re-touching on later launches. A denied folder just stays inaccessible (no crash).
    Runs on a background thread after a short delay so the menu bar is up before prompts stack.
    """
    marker = Path.home() / ".terminull" / ".folder_access_prewarmed"
    try:
        if marker.exists():
            return
    except Exception:
        return

    def _run() -> None:
        time.sleep(4)   # let the menu bar finish coming up before the system prompts stack
        home = Path.home()
        for sub in ("Desktop", "Documents", "Downloads"):
            p = home / sub
            try:
                if p.is_dir():
                    os.listdir(p)   # trips TCC → prompt (result is irrelevant)
            except Exception as e:
                log.info("folder prewarm: %s not accessible (%s)", sub, e)
        try:
            marker.parent.mkdir(parents=True, exist_ok=True)
            marker.write_text("")   # written even on denial — macOS won't re-prompt anyway
        except Exception:
            pass
        log.info("folder prewarm: done (Desktop/Documents/Downloads)")

    threading.Thread(target=_run, daemon=True, name="terminull-folder-prewarm").start()


def main() -> None:
    setup_logging()
    _start_daily_log_maintenance()   # daily cleanup + oversized-file truncation

    # First-run self-install: if user opened app from Downloads/Desktop/etc,
    # copy to ~/.terminull/Terminull.app and restart from there.
    # This ensures LaunchAgent can find the app on subsequent logins.
    if _needs_self_install():
        log.info("first run: installing to ~/.terminull/Terminull.app")
        if _self_install():
            _restart_from_install()
        else:
            log.warning("self-install failed, continuing from current location")

    if not _acquire_singleton():
        log.info("another Terminull instance is already running — exiting this duplicate")
        sys.exit(0)
    launchagent.install()
    # Conditionally install the Hermes gateway's own LaunchAgent — only on a machine with
    # a personal Hermes install (no-ops + retires any stale agent otherwise). Non-fatal.
    hermes_gateway.install()
    # NOTE: the macOS folder-access prompts (Desktop/Documents/Downloads) are NO LONGER fired
    # on a blind startup timer here. The first-run setup wizard now owns them — it shows a
    # "Files & Folders" page explaining why, then triggers the prompts ~2s later
    # (terminull_platform.mac.wizard._maybe_trigger_folder_access). `_prewarm_folder_access`
    # below is kept as a dormant fallback (unused) in case the wizard can't run.

    status_queue = make_status_queue()

    # Shared handle to the bridge app + its loop, populated by the bridge thread once started.
    bridge: dict = {}

    bridge_thread = threading.Thread(
        target=_run_bridge,
        args=(status_queue, bridge),
        daemon=True,
        name="terminull",
    )
    bridge_thread.start()

    def _install_sleep_observer() -> object | None:
        """End all Live Activities when macOS is about to sleep. The process freezes during
        sleep, so we push the `end` on `willSleep` while the network may still be up (best
        effort — iOS also reconciles LA state on wake). Sessions are NOT torn down (they
        survive the sleep). Returns the retained observer (must outlive registration)."""
        try:
            from AppKit import NSWorkspace
            from Foundation import NSObject
        except Exception as e:
            log.info("sleep observer unavailable (no AppKit): %s", e)
            return None

        def _on_sleep() -> None:
            app = bridge.get("app")
            loop = bridge.get("loop")
            if app is None or loop is None or not loop.is_running():
                return
            try:
                fut = asyncio.run_coroutine_threadsafe(app.stop_live_activities(), loop)
                fut.result(timeout=4.0)
                log.info("willSleep: live activities ended")
            except Exception as e:
                log.warning("willSleep: %s", e)

        class _SleepObserver(NSObject):
            def onSleep_(self, note):   # noqa: N802 — objc selector signature
                _on_sleep()

        observer = _SleepObserver.alloc().init()
        NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_(
            observer, b"onSleep:", "NSWorkspaceWillSleepNotification", None)
        log.info("registered willSleep observer (ends Live Activities on sleep)")
        return observer

    # Retain the observer for the process lifetime (objc weakly refs registrations).
    bridge["_sleep_observer"] = _install_sleep_observer()

    def _kick_relay(reason: str) -> None:
        """Schedule network-change recovery onto the bridge loop (from the main / dispatch
        thread). Recovers BOTH transports: the relay socket is half-open after a wake or
        network-path change (else the ~90 s WS keepalive is the only thing that notices),
        AND Bonjour must be re-published with the new IP or LAN clients resolve a dead
        A-record. app.on_network_changed does both."""
        app = bridge.get("app")
        loop = bridge.get("loop")
        if app is None or loop is None or not loop.is_running():
            return
        try:
            asyncio.run_coroutine_threadsafe(app.on_network_changed(reason), loop)
        except Exception as e:
            log.warning("relay kick (%s): %s", reason, e)

    def _install_wake_observer() -> object | None:
        """Force an immediate relay reconnect when macOS wakes from sleep. During sleep the
        process is frozen and the relay TCP socket goes half-open (no RST); on wake the
        passive WS keepalive would otherwise take up to ~90 s to notice, leaving the bridge
        'offline' to relay-only (LTE) clients long after the Mac is back. Returns the
        retained observer (must outlive registration)."""
        try:
            from AppKit import NSWorkspace
            from Foundation import NSObject
        except Exception as e:
            log.info("wake observer unavailable (no AppKit): %s", e)
            return None

        class _WakeObserver(NSObject):
            def onWake_(self, note):   # noqa: N802 — objc selector signature
                _kick_relay("wake-from-sleep")

        observer = _WakeObserver.alloc().init()
        NSWorkspace.sharedWorkspace().notificationCenter().addObserver_selector_name_object_(
            observer, b"onWake:", "NSWorkspaceDidWakeNotification", None)
        log.info("registered didWake observer (forces relay reconnect on wake)")
        return observer

    def _install_network_monitor() -> object | None:
        """Force an immediate relay reconnect when the network path changes (Wi-Fi switch,
        Wi-Fi↔Ethernet, VPN toggle) while awake — same half-open-socket problem as wake,
        but without a sleep event to hang the reconnect off. Uses Network.framework's
        nw_path_monitor; debounced so a burst of interface changes triggers at most one
        reconnect per _NET_KICK_DEBOUNCE_S. Degrades to no-op if the bindings are absent."""
        try:
            from Network import (
                nw_path_monitor_create,
                nw_path_monitor_set_update_handler,
                nw_path_monitor_set_queue,
                nw_path_monitor_start,
                nw_path_get_status,
                nw_path_status_satisfied,
            )
            import libdispatch
        except Exception as e:
            log.info("network monitor unavailable (no Network.framework bindings): %s", e)
            return None

        # Read the machine's physical IPs (utun/VPN excluded) — same source the bridge's
        # Bonjour advertise + netwatch loop use, so the trigger matches what actually needs
        # re-publishing.
        try:
            from terminull_bridge.local_server import _all_local_ips
        except Exception:
            _all_local_ips = lambda: []   # noqa: E731 — degrade to satisfied-transition only

        # Seed last_satisfied=True so a normal launch (network already up) doesn't fire a
        # spurious reconnect on the monitor's initial callback; a real drop→recovery still
        # transitions False→True and kicks.
        state = {"last_satisfied": True, "last_kick": 0.0, "last_ips": _all_local_ips()}

        def _on_path(path) -> None:
            try:
                satisfied = nw_path_get_status(path) == nw_path_status_satisfied
            except Exception:
                return
            now = time.monotonic()
            # Two triggers, both debounced (force_reconnect drops the live socket, so a
            # storm of path updates must not repeatedly kill a just-established connection):
            #   1. transition INTO 'satisfied' (network came back), and
            #   2. the physical IP set changed while staying satisfied — a Wi-Fi↔Wi-Fi switch
            #      (esp. with a VPN holding the path 'satisfied' throughout) produces NO
            #      false→true transition, so #1 alone silently misses it and LAN/relay both
            #      strand on the old address (the 2026-07-13 outage).
            became_reachable = satisfied and state["last_satisfied"] is not True
            state["last_satisfied"] = satisfied
            ips = _all_local_ips()
            ip_changed = bool(ips) and ips != state["last_ips"]
            if ip_changed:
                state["last_ips"] = ips
            if (became_reachable or ip_changed) and (now - state["last_kick"]) >= _NET_KICK_DEBOUNCE_S:
                state["last_kick"] = now
                _kick_relay("network-path change" if became_reachable else "ip-change")

        try:
            monitor = nw_path_monitor_create()
            nw_path_monitor_set_update_handler(monitor, _on_path)
            queue = libdispatch.dispatch_queue_create(b"app.terminull.netmon", None)
            nw_path_monitor_set_queue(monitor, queue)
            nw_path_monitor_start(monitor)
            log.info("registered network-path monitor (forces relay reconnect on path change)")
            return monitor
        except Exception as e:
            log.info("network monitor setup failed: %s", e)
            return None

    bridge["_wake_observer"] = _install_wake_observer()
    bridge["_net_monitor"] = _install_network_monitor()

    def _shutdown_bridge() -> None:
        """Stop adapter subprocesses before Quit/Restart hard-kills the daemon bridge thread.
        Runs on the main thread; hops to the bridge loop and waits (bounded) for teardown.
        Without this, persistent-server adapters (kilo/opencode serve, …) orphan their detached
        child on every Quit — the Mac process never reaches headless's SIGTERM handler."""
        app = bridge.get("app")
        loop = bridge.get("loop")
        if app is None or loop is None or not loop.is_running():
            log.info("bridge shutdown: nothing to stop (app/loop not ready)")
            return
        try:
            fut = asyncio.run_coroutine_threadsafe(app.stop_adapter_subprocesses(), loop)
            fut.result(timeout=6.0)
            log.info("bridge shutdown: adapter subprocesses stopped")
        except Exception as e:
            log.warning("bridge shutdown: %s", e)

    run_menubar(status_queue, api_url=API_URL, shutdown_cb=_shutdown_bridge)


if __name__ == "__main__":
    main()
