"""
Background agent (LaunchAgent): IPC via Unix socket, start/stop via launchctl.
Used by app_pyobjc2 delegate; does not import app_pyobjc2.
"""
import os
import sys
import json
import subprocess
import socket
import time

from persistence import MUGI_DIR, SOCKET_PATH, PROACTIVE_QUEUE_PATH
from utils.path_safety import secure_write_file

# Stable copy of the Python backend used by launchd. Xcode Debug / DerivedData paths
# in the plist break after a clean build; ~/.mugi is durable.
LAUNCHD_BACKEND_STAGING_DIR = os.path.join(MUGI_DIR, "launchd_backend")

# PyObjC for _poll_proactive_queue (NSApp.keyWindow())
try:
    from AppKit import NSApp
except ImportError:
    NSApp = None

# -----------------------------------------------------------------------------
# Background agent (LaunchAgent) — IPC via Unix socket, start/stop via launchctl
# Security: socket path is user-only (~/.mugi/agent.sock, chmod 600).
# No TCP; strict command allow-list in runner. See mugi_runner.py.
# -----------------------------------------------------------------------------
LAUNCHAGENT_PLIST_PATH = os.path.join(os.path.expanduser("~/Library/LaunchAgents"), "com.mugi.launchd.runner.plist")
# Must not be bare "com.mugi" — launchd returns EIO on modern macOS when the label matches /
# conflicts with the GUI app bundle id family (com.mugi.app / com.mugi.app.debug).
LAUNCHAGENT_LABEL = "com.mugi.launchd.runner"
_LEGACY_LAUNCHAGENT_PLIST = os.path.expanduser("~/Library/LaunchAgents/com.mugi.plist")
_LEGACY_LAUNCHAGENT_LABELS = ("com.mugi",)
# When present, the main app will re-bootstrap the LaunchAgent after quit (quit always bootouts).
BACKGROUND_AGENT_DESIRED_PATH = os.path.join(os.path.expanduser("~/.mugi"), ".background_agent_desired")


def _preferred_python_for_launchd() -> str:
    """Prefer the managed venv interpreter so launchd matches the in-app backend."""
    venv_py = os.path.join(MUGI_DIR, "venv", "bin", "python3")
    if os.path.isfile(venv_py) and os.access(venv_py, os.X_OK):
        return venv_py
    return sys.executable


def _bootout_legacy_background_launchagents() -> None:
    """Remove pre-2026 plist/label that no longer load under launchd (label com.mugi)."""
    uid = os.getuid()
    domain = f"gui/{uid}"
    plist = os.path.expanduser(_LEGACY_LAUNCHAGENT_PLIST)
    if os.path.isfile(plist):
        try:
            subprocess.run(
                ["launchctl", "bootout", domain, plist],
                capture_output=True,
                timeout=10,
            )
        except Exception:
            pass
        for lbl in _LEGACY_LAUNCHAGENT_LABELS:
            try:
                subprocess.run(
                    ["launchctl", "bootout", f"{domain}/{lbl}"],
                    capture_output=True,
                    timeout=10,
                )
            except Exception:
                pass
        try:
            os.remove(plist)
        except OSError:
            pass


def set_background_agent_desired_running(desired: bool) -> None:
    """Persist whether the user wants the LaunchAgent loaded (survives app quit + bootout)."""
    try:
        if desired:
            secure_write_file(BACKGROUND_AGENT_DESIRED_PATH, "1\n", mode=0o600)
        elif os.path.isfile(BACKGROUND_AGENT_DESIRED_PATH):
            os.remove(BACKGROUND_AGENT_DESIRED_PATH)
    except OSError:
        pass


def reload_background_agent_if_desired() -> None:
    """If the plist is installed and the user had left the agent enabled, load it after app launch."""
    if not _background_agent_is_installed():
        return
    if _background_agent_ping():
        # Migrate older installs: agent running but no marker file yet.
        if not os.path.isfile(BACKGROUND_AGENT_DESIRED_PATH):
            set_background_agent_desired_running(True)
        return
    if not os.path.isfile(BACKGROUND_AGENT_DESIRED_PATH):
        return
    _launchctl_bootstrap()


def _background_agent_ping():
    """Connect to background runner Unix socket, send 'ping', return True if ok else False."""
    path = os.path.expanduser(SOCKET_PATH)
    if not os.path.exists(path):
        return False
    try:
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.settimeout(2.0)
        sock.connect(path)
        sock.sendall(b'ping\n')
        buf = b""
        while b"\n" not in buf and len(buf) < 4096:
            buf += sock.recv(256)
        sock.close()
        line = buf.decode("utf-8", errors="replace").split("\n")[0].strip()
        if not line:
            return False
        data = json.loads(line)
        return data.get("status") == "ok"
    except Exception:
        return False


def _background_agent_send_command(cmd):
    """Send one command to background runner; return response dict or None on error."""
    path = os.path.expanduser(SOCKET_PATH)
    if not os.path.exists(path):
        return None
    try:
        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        sock.settimeout(30.0)
        sock.connect(path)
        sock.sendall((cmd.strip().lower() + "\n").encode("utf-8"))
        buf = b""
        while b"\n" not in buf and len(buf) < 65536:
            buf += sock.recv(1024)
        sock.close()
        line = buf.decode("utf-8", errors="replace").split("\n")[0].strip()
        return json.loads(line) if line else None
    except Exception:
        return None


def _launchctl_bootstrap():
    """Load LaunchAgent (start background runner). Returns (success, message)."""
    _bootout_legacy_background_launchagents()
    ok_sync, sync_err = _sync_launchd_backend_staging()
    if not ok_sync:
        return False, sync_err
    _remove_stale_agent_socket_if_dead()
    runner_script = os.path.join(LAUNCHD_BACKEND_STAGING_DIR, "mugi_runner.py")
    py = _preferred_python_for_launchd()
    ok_plist, plist_err = _write_launchagent_plist(py, runner_script)
    if not ok_plist:
        return False, plist_err
    path = os.path.expanduser(LAUNCHAGENT_PLIST_PATH)
    uid = os.getuid()
    dom = f"gui/{uid}"
    try:
        subprocess.run(
            ["launchctl", "bootout", dom, path],
            capture_output=True,
            timeout=10,
        )
        subprocess.run(
            ["launchctl", "bootout", f"{dom}/{LAUNCHAGENT_LABEL}"],
            capture_output=True,
            timeout=10,
        )
    except Exception:
        pass
    try:
        subprocess.run(
            ["launchctl", "bootstrap", dom, path],
            check=True,
            capture_output=True,
            timeout=10,
        )
        set_background_agent_desired_running(True)
        return True, "Background agent started."
    except subprocess.CalledProcessError as e:
        err = (e.stderr or b"").decode("utf-8", errors="replace").strip()
        return False, err or "launchctl bootstrap failed."
    except Exception as e:
        return False, str(e)


def _launchd_print_shows_running(label: str) -> bool:
    """True if launchctl print finds the job and reports state = running."""
    uid = os.getuid()
    try:
        r = subprocess.run(
            ["launchctl", "print", f"gui/{uid}/{label}"],
            capture_output=True,
            text=True,
            timeout=5,
        )
    except (OSError, subprocess.TimeoutExpired):
        return False
    if r.returncode != 0:
        return False
    out = r.stdout or ""
    return "state = running" in out


def _launchctl_bootout():
    """Unload LaunchAgent (stop background runner). Returns (success, message)."""
    path = os.path.expanduser(LAUNCHAGENT_PLIST_PATH)
    uid = os.getuid()
    dom = f"gui/{uid}"
    bootout_errors: list[str] = []

    def _try_bootout(args: list[str]) -> None:
        try:
            r = subprocess.run(args, capture_output=True, timeout=10)
        except (OSError, subprocess.TimeoutExpired) as e:
            bootout_errors.append(str(e))
            return
        if r.returncode != 0:
            err = (r.stderr or b"").decode("utf-8", errors="replace").strip()
            if err:
                bootout_errors.append(err)

    _try_bootout(["launchctl", "bootout", dom, path])
    _try_bootout(["launchctl", "bootout", f"{dom}/{LAUNCHAGENT_LABEL}"])
    _try_bootout(["launchctl", "bootout", dom, os.path.expanduser(_LEGACY_LAUNCHAGENT_PLIST)])
    for lbl in _LEGACY_LAUNCHAGENT_LABELS:
        _try_bootout(["launchctl", "bootout", f"{dom}/{lbl}"])

    time.sleep(0.35)
    if _background_agent_ping():
        hint = "; ".join(bootout_errors[-2:]) if bootout_errors else ""
        msg = "Background agent IPC still responds after launchctl bootout."
        if hint:
            msg += f" ({hint})"
        return False, msg
    for lbl in (LAUNCHAGENT_LABEL,) + tuple(_LEGACY_LAUNCHAGENT_LABELS):
        if _launchd_print_shows_running(lbl):
            return False, f"launchd still reports {lbl!r} as running after bootout."
    return True, "Background agent stopped."


def _restart_background_agent():
    """Restart the background agent (bootout then bootstrap). Returns (success: bool, message: str)."""
    if not _background_agent_is_installed():
        return False, "Background agent plist not found. Install the background agent first."
    ok, msg = _launchctl_bootout()
    if not ok:
        return False, "Restart failed (stop): " + msg
    ok2, msg2 = _launchctl_bootstrap()
    if not ok2:
        return False, "Restart failed (start): " + msg2
    return True, "Background agent restarted."


def _poll_proactive_queue(delegate):
    """
    Read proactive_queue.jsonl, take newest entry that is unprocessed and not yet notified,
    inject as agent message into the current tab's chat, then mark processed and notified (rewrite file).
    Entries with "notified": true are skipped to avoid duplicate in-chat alerts.
    Security: queue path is under ~/.mugi/; only user can read (chmod 600).
    """
    path = os.path.expanduser(PROACTIVE_QUEUE_PATH)
    if not os.path.isfile(path):
        return
    try:
        with open(path, "r", encoding="utf-8") as f:
            lines = f.readlines()
    except OSError:
        return
    entries = []
    for line in lines:
        line = line.strip()
        if not line:
            continue
        try:
            entries.append(json.loads(line))
        except json.JSONDecodeError:
            continue
    # Show only entries not yet processed and not already shown in chat (notified)
    to_show = [e for e in entries if not e.get("processed") and not e.get("notified")]
    if not to_show:
        return
    # Newest is last in file
    entry = to_show[-1]
    content = (entry.get("content") or "").strip()
    if not content:
        entry["processed"] = True
        entry["notified"] = True
    else:
        key_win = NSApp.keyWindow() if NSApp else None
        tab = delegate.tabForWindow_(key_win) if key_win else None
        thread_id = (tab.get("current_thread_id") if tab else None) or None
        delegate.addMessage_withIsUser_bubbleType_threadId_(content, is_user=False, bubble_type="agent", thread_id=thread_id)
        entry["processed"] = True
        entry["notified"] = True
    # Rewrite file with this entry marked processed and notified
    for i, e in enumerate(entries):
        if e is entry:
            entries[i] = {**e, "processed": True, "notified": True}
            break
    try:
        content = "\n".join(json.dumps(e) for e in entries) + ("\n" if entries else "")
        secure_write_file(path, content, mode=0o600)
    except OSError:
        pass


def _background_agent_is_installed():
    """True if a LaunchAgent plist (current or legacy) exists in ~/Library/LaunchAgents/."""
    if os.path.isfile(os.path.expanduser(LAUNCHAGENT_PLIST_PATH)):
        return True
    return os.path.isfile(os.path.expanduser(_LEGACY_LAUNCHAGENT_PLIST))


def _xml_esc(s):
    """Escape string for use inside XML element content."""
    if s is None:
        return ""
    return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")


def _sync_launchd_backend_staging() -> tuple[bool, str]:
    """Populate ~/.mugi/launchd_backend from the live Backend tree (bundle or repo).

    Uses the same allowlist as scripts/copy_backend_bundle.sh so launchd does not
    depend on ephemeral .app paths under DerivedData.
    """
    here = os.path.dirname(os.path.abspath(__file__))
    script = os.path.join(here, "scripts", "copy_backend_bundle.sh")
    if not os.path.isfile(script):
        return False, f"Backend staging script missing: {script}"
    if not os.path.isfile(os.path.join(here, "mugi_runner.py")):
        return False, f"mugi_runner.py not found in {here}"
    try:
        subprocess.run(
            ["bash", script, here, LAUNCHD_BACKEND_STAGING_DIR],
            check=True,
            capture_output=True,
            timeout=180,
            text=True,
        )
    except subprocess.CalledProcessError as e:
        err = (e.stderr or e.stdout or "").strip()
        return False, err or f"Backend staging failed (exit {e.returncode})"
    except (OSError, subprocess.TimeoutExpired) as e:
        return False, str(e)
    if not os.path.isfile(os.path.join(LAUNCHD_BACKEND_STAGING_DIR, "mugi_runner.py")):
        return False, f"Staged runner missing under {LAUNCHD_BACKEND_STAGING_DIR}"
    return True, ""


def _remove_stale_agent_socket_if_dead() -> None:
    """If agent.sock exists but nothing answers ping, unlink so a fresh runner can bind."""
    path = os.path.expanduser(SOCKET_PATH)
    if not os.path.exists(path):
        return
    if _background_agent_ping():
        return
    try:
        os.unlink(path)
    except OSError:
        pass


def _launchagent_plist_body(python_path: str, runner_script: str) -> str:
    home = os.path.expanduser("~")
    workspace = os.path.join(home, ".mugi", "workspace")
    logs_dir = os.path.join(home, ".mugi", "launchd_logs")
    stdout_log = os.path.join(logs_dir, "stdout.log")
    stderr_log = os.path.join(logs_dir, "stderr.log")
    return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Label</key>
	<string>{_xml_esc(LAUNCHAGENT_LABEL)}</string>
	<key>ProgramArguments</key>
	<array>
		<string>{_xml_esc(python_path)}</string>
		<string>{_xml_esc(runner_script)}</string>
		<string>--heartbeat-interval</string>
		<string>30</string>
	</array>
	<key>RunAtLoad</key>
	<true/>
	<key>KeepAlive</key>
	<true/>
	<key>ThrottleInterval</key>
	<integer>60</integer>
	<key>WorkingDirectory</key>
	<string>{_xml_esc(workspace)}</string>
	<key>StandardOutPath</key>
	<string>{_xml_esc(stdout_log)}</string>
	<key>StandardErrorPath</key>
	<string>{_xml_esc(stderr_log)}</string>
</dict>
</plist>
"""


def _write_launchagent_plist(python_path: str, runner_script: str) -> tuple[bool, str]:
    launch_agents_dir = os.path.join(os.path.expanduser("~"), "Library", "LaunchAgents")
    plist_path = os.path.expanduser(LAUNCHAGENT_PLIST_PATH)
    logs_dir = os.path.join(os.path.expanduser("~"), ".mugi", "launchd_logs")
    try:
        os.makedirs(launch_agents_dir, exist_ok=True)
        os.makedirs(logs_dir, exist_ok=True)
    except OSError as e:
        return False, f"Cannot create directories: {e}"
    try:
        secure_write_file(plist_path, _launchagent_plist_body(python_path, runner_script), mode=0o600)
    except OSError as e:
        return False, f"Cannot write plist: {e}"
    return True, ""


def _install_background_agent():
    """
    Install Background Agent: stage backend under ~/.mugi/launchd_backend, create plist
    pointing at the staged mugi_runner.py (not ephemeral DerivedData paths), chmod 600.
    Returns (success, message).
    """
    ok, msg = _launchctl_bootstrap()
    if ok:
        return True, "Background Agent installed and started."
    return False, msg


def _uninstall_background_agent():
    """
    Uninstall Background Agent: unload via launchctl bootout (if loaded), then remove the plist.
    Returns (success, message).
    """
    set_background_agent_desired_running(False)
    had_plist = _background_agent_is_installed()
    _bootout_legacy_background_launchagents()
    uid = os.getuid()
    dom = f"gui/{uid}"
    for plist in (LAUNCHAGENT_PLIST_PATH, _LEGACY_LAUNCHAGENT_PLIST):
        p = os.path.expanduser(plist)
        if not os.path.isfile(p):
            continue
        try:
            subprocess.run(
                ["launchctl", "bootout", dom, p],
                capture_output=True,
                timeout=10,
            )
        except Exception:
            pass
        try:
            os.remove(p)
        except OSError:
            pass
    try:
        subprocess.run(
            ["launchctl", "bootout", f"{dom}/{LAUNCHAGENT_LABEL}"],
            capture_output=True,
            timeout=10,
        )
    except Exception:
        pass
    if not had_plist:
        return True, "Background Agent was not installed."
    if not _background_agent_is_installed():
        return True, "Background Agent uninstalled (plist removed)."
    return True, "Background Agent unloaded; one plist could not be removed."
