"""The dock's Browser and agent-browser resolve to one identity: same executable, same user-data-dir —
and when a human opened that browser first, the agent attaches to it instead of launching a second one
(Chromium's profile singleton would forward the launch and kill it without a DevTools endpoint)."""

from __future__ import annotations

import os
import socket
from pathlib import Path
from types import SimpleNamespace

import pytest

from tools.bot_desktop import browser, runtime


def test_dock_and_agent_share_browser_identity(tmp_path, monkeypatch):
    exe = tmp_path / "chrome"
    exe.write_text("#!/bin/sh\n", encoding="utf-8")
    exe.chmod(0o755)
    monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(exe))
    monkeypatch.delenv("AGENT_BROWSER_PROFILE", raising=False)
    monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")

    dock_exe, dock_profile = browser.dock_launch()
    agent_env = browser.env_for_agent({})

    assert dock_exe == agent_env["AGENT_BROWSER_EXECUTABLE_PATH"] == str(exe)
    assert dock_profile == agent_env["AGENT_BROWSER_PROFILE"] == str(tmp_path / "bot-desktop" / "browser-profile")


def test_user_pinned_profile_wins(tmp_path, monkeypatch):
    monkeypatch.setenv("AGENT_BROWSER_PROFILE", str(tmp_path / "mine"))
    monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
    assert browser.profile_dir() == tmp_path / "mine"


def test_pinned_profile_honours_tilde_and_resolves_relative_paths_against_hermes_home(tmp_path, monkeypatch):
    """Regression for #110029: the docs say setting AGENT_BROWSER_PROFILE pins your own user-data-dir, but only
    an absolute value was honoured — `~/pin` and `pin` silently fell back to the default and the human's dock
    browser and the agent's browser could end up on different jars. A relative path is anchored where the rest
    of this profile's screen state lives (its HERMES_HOME), so two profiles never share one 'pin'."""
    home = tmp_path / "hermes-home"
    monkeypatch.setenv("HERMES_HOME", str(home))
    monkeypatch.setattr(runtime, "get_hermes_home", lambda: home)
    monkeypatch.setattr(Path, "home", lambda: tmp_path / "user")
    monkeypatch.setenv("HOME", str(tmp_path / "user"))

    monkeypatch.setenv("AGENT_BROWSER_PROFILE", "~/pin")
    assert browser.profile_dir() == tmp_path / "user" / "pin"
    monkeypatch.setenv("AGENT_BROWSER_PROFILE", "pin")
    assert browser.profile_dir() == home / "pin"
    assert browser.env_for_agent({})["AGENT_BROWSER_PROFILE"] == str(home / "pin"), "agent-browser gets the resolved path"


def test_dock_browser_advertises_a_devtools_port():
    """A human-started instance must be attachable, or the agent can never drive it afterwards."""
    assert "--remote-debugging-port=" in browser.dock_argv("/opt/chrome", "/p/dir")[2]


def test_dock_browser_caps_its_disk_cache():
    """The persistent profile lives on the gateway's disk (6 GB on a hosted instance); the HTTP cache
    must not be allowed to grow without bound there."""
    argv = browser.dock_argv("/opt/chrome", "/p/dir")
    cap = next(a for a in argv if a.startswith("--disk-cache-size="))
    assert 0 < int(cap.split("=", 1)[1]) <= 512 * 1024 * 1024


def _fake_running_instance(user_data_dir, pid: int, port: int) -> None:
    (user_data_dir / "DevToolsActivePort").write_text(f"{port}\n/devtools/browser/abc\n", encoding="utf-8")
    os.symlink(f"host-{pid}", user_data_dir / "SingletonLock")


def test_running_instance_port_requires_live_pid_and_open_port(tmp_path):
    listener = socket.socket()
    listener.bind(("127.0.0.1", 0))
    listener.listen(1)
    port = listener.getsockname()[1]
    try:
        _fake_running_instance(tmp_path, os.getpid(), port)
        assert browser.running_instance_cdp_port(str(tmp_path)) == port

        # Both files outlive a closed Chromium: a dead pid must not be trusted.
        os.unlink(tmp_path / "SingletonLock")
        os.symlink("host-2147483000", tmp_path / "SingletonLock")
        assert browser.running_instance_cdp_port(str(tmp_path)) is None
    finally:
        listener.close()
    # Live pid, port no longer accepting: still not attachable.
    os.unlink(tmp_path / "SingletonLock")
    os.symlink(f"host-{os.getpid()}", tmp_path / "SingletonLock")
    assert browser.running_instance_cdp_port(str(tmp_path)) is None
    assert browser.running_instance_cdp_port(str(tmp_path / "missing")) is None


def test_agent_attaches_to_human_started_browser(monkeypatch):
    """With a live dock instance on the shared profile the local argv carries ``--cdp <port>``; without one
    it stays a plain ``--session`` launch."""
    from tools import browser_tool_session as session

    monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":37"})
    monkeypatch.setattr(session._cloud, "_get_browser_engine", lambda: "auto")
    monkeypatch.setattr(session._cloud, "_is_headed_mode", lambda: False)
    monkeypatch.setattr(session, "_agent_browser_argv", lambda cmd: [cmd])
    argvs: list = []

    def spawn(task_id, session_info, cmd_parts, *rest):
        argvs.append(cmd_parts)
        return {"success": True}

    monkeypatch.setattr(session, "_spawn_and_collect", spawn)
    monkeypatch.setattr(session._lp, "_lightpanda_fallback_reason", lambda *a: None)
    info = {"session_name": "h_abc", "cdp_url": None, "features": {"local": True}}

    monkeypatch.setattr(browser, "running_instance_cdp_port", lambda d, **kw: 41234)
    session._dispatch_browser_command("t", info, "agent-browser", "open", ["https://x"], 10, None)
    assert argvs[-1][:5] == ["agent-browser", "--session", "h_abc", "--cdp", "41234"]

    monkeypatch.setattr(browser, "running_instance_cdp_port", lambda d, **kw: None)
    session._dispatch_browser_command("t", info, "agent-browser", "open", ["https://x"], 10, None)
    assert "--cdp" not in argvs[-1] and argvs[-1][:3] == ["agent-browser", "--session", "h_abc"]


def _install_browsers(tmp_path, monkeypatch, *, playwright: bool, system: bool):
    """A Playwright build under a private PLAYWRIGHT_BROWSERS_PATH and/or a system chromium on PATH."""
    monkeypatch.delenv("AGENT_BROWSER_EXECUTABLE_PATH", raising=False)
    roots = tmp_path / "pw"
    roots.mkdir(parents=True)
    monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(roots))
    pw_exe = roots / "chromium-1200" / "chrome-linux" / "chrome"
    if playwright:
        pw_exe.parent.mkdir(parents=True)
        pw_exe.write_text("#!/bin/sh\n", encoding="utf-8")
        pw_exe.chmod(0o755)
    monkeypatch.setattr(
        "hermes_cli.browser_runtime.chromium_executable",
        lambda *, allow_override=True: str(pw_exe) if playwright else None,
    )
    sys_exe = tmp_path / "bin" / "chromium"
    if system:
        sys_exe.parent.mkdir(parents=True)
        sys_exe.write_text("#!/bin/sh\n", encoding="utf-8")
        sys_exe.chmod(0o755)
    monkeypatch.setattr("shutil.which", lambda name, *a, **k: str(sys_exe) if system and name == "chromium" else None)
    return str(pw_exe), str(sys_exe)


def test_unprivileged_user_under_apparmor_userns_restriction_gets_the_system_browser(tmp_path, monkeypatch):
    """Playwright's bundled Chromium has no setuid chrome_sandbox; with
    kernel.apparmor_restrict_unprivileged_userns=1 it dies 'FATAL: No usable sandbox!' for a non-root user,
    so the dock icon is dead. A distro chromium (which ships the sandbox helper) must win there — and
    the Playwright build stays the answer when it is the only one, started with exactly the flags
    agent-browser starts it with on that host (one sandbox policy for the human's and the bot's browser)."""
    pw_exe, sys_exe = _install_browsers(tmp_path, monkeypatch, playwright=True, system=True)
    monkeypatch.setattr(browser.os, "geteuid", lambda: 1000)
    monkeypatch.setattr(browser, "_userns_restricted", lambda: True)
    assert browser.executable() == sys_exe

    monkeypatch.setattr(browser, "_userns_restricted", lambda: False)
    assert browser.executable() == pw_exe  # unrestricted host: Playwright's build as before

    _install_browsers(tmp_path / "only-pw", monkeypatch, playwright=True, system=False)
    monkeypatch.setattr(browser, "_userns_restricted", lambda: True)
    exe = browser.executable()
    assert exe and exe.endswith("chrome-linux/chrome")
    from tools import browser_tool_session as session

    agent_env: dict = {}
    session._apply_chromium_sandbox_args(agent_env)
    agent_flags = set(agent_env.get("AGENT_BROWSER_ARGS", "").split(",")) - {""}
    assert agent_flags <= set(browser.dock_argv(exe, "/p/dir"))
    assert ("--no-sandbox" in browser.dock_argv(exe, "/p/dir")) == ("--no-sandbox" in agent_flags)


def test_root_dock_browser_starts_with_the_same_sandbox_args_as_the_agents_browser(monkeypatch):
    """Chromium refuses to start as root without --no-sandbox; agent-browser gets that flag from one
    policy, and the dock icon (same binary, same profile) must get the very same flags or the human's
    click dies while the agent's launch works."""
    from tools import browser_tool_session as session

    monkeypatch.setattr(session.os, "geteuid", lambda: 0)
    monkeypatch.setattr(browser.os, "geteuid", lambda: 0)
    agent_env: dict = {}
    session._apply_chromium_sandbox_args(agent_env)
    agent_flags = set(agent_env["AGENT_BROWSER_ARGS"].split(","))
    assert agent_flags, "root must inject sandbox flags for agent-browser"
    assert agent_flags <= set(browser.dock_argv("/opt/chrome", "/p/dir"))

    # Same policy for the NON-root container case (the official image runs the gateway as uid 10000 in
    # Docker): agent-browser bypasses the sandbox there, and the dock icon must too or it dies on click.
    monkeypatch.setattr(session.os, "geteuid", lambda: 10000)
    monkeypatch.setattr(browser.os, "geteuid", lambda: 10000)
    monkeypatch.setattr(session._install, "_running_in_docker", lambda: True)
    docker_env: dict = {}
    session._apply_chromium_sandbox_args(docker_env)
    assert set(docker_env["AGENT_BROWSER_ARGS"].split(",")) <= set(browser.dock_argv("/opt/chrome", "/p/dir"))
    monkeypatch.setattr(session._install, "_running_in_docker", lambda: False)
    monkeypatch.setattr(session, "apparmor_restricts_unprivileged_userns", lambda: False)
    assert "--no-sandbox" not in browser.dock_argv("/opt/chrome", "/p/dir")  # seated non-root host: sandboxed


def test_status_reports_the_headed_browser_or_its_absence(monkeypatch):
    """The official image ships only chromium_headless_shell: executable() is None and the dock silently
    has no Browser icon. Status must say so instead of leaving the pane to guess. The subject is the
    browser field, not host policy: status() gates it on is_supported_host(), which is pinned True so the
    test means the same thing on every CI lane."""
    monkeypatch.setattr(runtime, "is_supported_host", lambda: True)
    monkeypatch.setattr(runtime, "_launcher_pid", lambda: None)
    monkeypatch.setattr(runtime, "published_env", lambda: {})
    monkeypatch.setattr(runtime, "geometry", lambda: "1440x900")
    monkeypatch.setattr(browser, "executable", lambda: None)
    assert runtime.status().as_dict()["browser"] is None
    monkeypatch.setattr(browser, "executable", lambda: "/usr/bin/chromium")
    assert runtime.status().browser == "/usr/bin/chromium"


def test_dock_exec_line_survives_spaces_in_the_executable_and_profile_paths():
    """The launcher used to split the shell line on the first space to find the executable, so a
    Chromium under '/opt/Google Chrome/' or a profile under a spaced HERMES_HOME broke the dock icon.
    Exec= follows the Desktop Entry spec: each argument double-quoted, with the reserved characters
    backslash-escaped inside the quotes."""
    exe = "/opt/Google Chrome/chrome"
    profile = '/home/a b/.hermes/browser "x"/profile'
    line = browser.dock_exec_line(exe, profile)
    assert line.startswith('Exec="/opt/Google Chrome/chrome" ')
    assert r'"--user-data-dir=/home/a b/.hermes/browser \\"x\\"/profile"' in line  # spec: \" quoted, then \ string-escaped
    assert "--remote-debugging-port=0" in line


def test_headless_shell_override_is_not_a_headed_browser(tmp_path, monkeypatch):
    """The official Docker image ships only Playwright's chrome-headless-shell and its boot hook exports it
    as AGENT_BROWSER_EXECUTABLE_PATH. That binary cannot open a window: taken at face value the dock's
    Browser icon would point at it and status would claim a headed browser exists. Live in the image:
    status.browser named the headless shell while the dock had no working Browser."""
    shell = tmp_path / "shell" / "chromium_headless_shell-1243" / "chrome-headless-shell-linux64" / "chrome-headless-shell"
    shell.parent.mkdir(parents=True)
    shell.write_text("#!/bin/sh\n", encoding="utf-8")
    shell.chmod(0o755)
    _install_browsers(tmp_path, monkeypatch, playwright=False, system=False)
    monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(shell))
    assert browser.executable() is None

    _, sys_exe = _install_browsers(tmp_path / "with-sys", monkeypatch, playwright=False, system=True)
    monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(shell))
    assert browser.executable() == sys_exe  # a real headed browser elsewhere still wins over the override


def test_headless_override_does_not_hide_pm_headed_browser(tmp_path, monkeypatch):
    import pm
    from hermes_cli.browser_runtime import chromium_executable

    headless = tmp_path / "chrome-headless-shell"
    headed = tmp_path / "chrome"
    for exe in (headless, headed):
        exe.write_text("#!/bin/sh\n", encoding="utf-8")
        exe.chmod(0o755)
    monkeypatch.setenv("AGENT_BROWSER_EXECUTABLE_PATH", str(headless))
    monkeypatch.setattr(pm, "installed_package", lambda name: SimpleNamespace(binary=headed))
    monkeypatch.setattr(browser, "_system_executable", lambda: None)
    monkeypatch.setattr(browser, "_userns_restricted", lambda: False)

    assert chromium_executable() == str(headless)  # ordinary headless browsing keeps its override
    dock = browser.dock_launch()
    assert dock is not None and dock[0] == str(headed)
    assert browser.env_for_agent({})["AGENT_BROWSER_EXECUTABLE_PATH"] == str(headed)


@pytest.mark.parametrize("engine, headed, starts", [("chrome", True, 1), ("chrome", False, 0), ("lightpanda", True, 0)])
def test_headed_chromium_spawn_asks_the_screen_to_start_but_the_env_builder_never_does(tmp_path, monkeypatch, engine, headed, starts):
    """Regression for #110050 at the right boundary: a real browser command that forks a headed Chromium daemon
    starts the profile's screen (bot_desktop.auto_start) like computer_use dispatch does; `_build_browser_env()`
    itself stays pure — it also serves the npx cache warmer, the Chromium auto-installer and the Lightpanda
    engine, none of which may block on Xvnc+Xfce coming up."""
    from tools import browser_tool as bt
    from tools import browser_tool_cloud as cloud
    from tools import browser_tool_session as session

    calls: list = []
    monkeypatch.setattr(runtime, "ensure_started_for_tool", lambda: calls.append(1))
    monkeypatch.setattr(runtime, "published_env", lambda: {})
    monkeypatch.setattr(cloud, "_is_headed_mode", lambda: headed)
    bt._build_browser_env()
    assert calls == []

    class _Done:
        returncode = 0
        def wait(self, timeout=None): return 0
    def _fake_popen(argv, env, socket_dir, tag, stdin_payload=None):
        for slot in ("stdout", "stderr"):
            Path(socket_dir, f"_{slot}_{tag}").write_text("{}" if slot == "stdout" else "", encoding="utf-8")
        return _Done()
    monkeypatch.setattr(session, "_popen_agent_browser", _fake_popen)
    monkeypatch.setattr(session, "_prepare_session_socket_dir", lambda name: str(tmp_path))
    session._spawn_and_collect("t", {"session_name": "h_x"}, ["agent-browser"], "open", engine, 5)
    assert len(calls) == starts


def test_janitor_keeps_the_shared_browser_alive_while_a_human_holds_the_lease(monkeypatch):
    """#110064: the agent goes idle BECAUSE the human took over to log in; the janitor must count the human's
    lease as activity for the shared local browser, and reap it again once the lease is handed back."""
    from tools import browser_tool_lifecycle as lifecycle, browser_tool_session as session
    from tools.bot_desktop import lease

    monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":37"})
    holder = {"human": True}
    monkeypatch.setattr(lease, "human_holds", lambda *a, **k: holder["human"])
    reaped: list = []
    monkeypatch.setattr(lifecycle, "cleanup_browser", lambda task_id: reaped.append(task_id))
    monkeypatch.setattr(lifecycle._bt, "BROWSER_SESSION_INACTIVITY_TIMEOUT", 1)
    monkeypatch.setattr(lifecycle._bt, "_active_sessions", {
        "bot": {"session_name": "h_bot", "features": {"local": True}},
        "cloud": {"session_name": "c_1", "bb_session_id": "bb", "features": {}},
    })
    monkeypatch.setattr(lifecycle._bt, "_session_last_activity", {"bot": 0.0, "cloud": 0.0})
    monkeypatch.setattr(lifecycle._bt, "_session_owner_homes", {})

    lifecycle._cleanup_inactive_browser_sessions()
    assert reaped == ["cloud"], "only the browser the human is not typing into may be reaped"
    assert lifecycle._bt._session_last_activity["bot"] > 0, "the human's lease refreshed the bot browser's activity"

    holder["human"] = False
    lifecycle._bt._session_last_activity["bot"] = 0.0
    lifecycle._cleanup_inactive_browser_sessions()
    assert reaped == ["cloud", "bot"]
    assert session.human_holds_shared_browser({"features": {"local": True}}) is False


def test_daemon_idle_timer_defers_to_the_janitor_only_for_the_shared_headed_browser(monkeypatch):
    """The agent-browser daemon cannot see the lease, so on the Bot Desktop its self-termination timer steps
    back and the lease-aware janitor owns the browser's lifetime; headless browsing keeps the mirror."""
    from tools import browser_tool_session as session

    monkeypatch.setattr(session._bt, "BROWSER_SESSION_INACTIVITY_TIMEOUT", 120)
    monkeypatch.setattr(runtime, "published_env", lambda: {"DISPLAY": ":37"})
    monkeypatch.setattr(session._cloud, "_is_headed_mode", lambda: True)
    assert session._daemon_idle_timeout_seconds() > 120
    monkeypatch.setattr(session._cloud, "_is_headed_mode", lambda: False)
    assert session._daemon_idle_timeout_seconds() == 120
    monkeypatch.setattr(session._cloud, "_is_headed_mode", lambda: True)
    monkeypatch.setattr(runtime, "published_env", lambda: {})
    assert session._daemon_idle_timeout_seconds() == 120


def test_a_headless_shell_pin_is_replaced_while_a_screen_is_up(tmp_path, monkeypatch):
    """The boot hook exports a chrome-headless-shell path; leaving it would put the agent and the dock on
    two binaries over one --user-data-dir, where the singleton swallows the dock's launch."""
    shell = tmp_path / "chrome-headless-shell"
    shell.write_text("#!/bin/sh\n", encoding="utf-8")
    shell.chmod(0o755)
    headed = tmp_path / "chrome"
    headed.write_text("#!/bin/sh\n", encoding="utf-8")
    headed.chmod(0o755)
    monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
    monkeypatch.setattr(browser, "_managed_executable", lambda: str(headed))
    monkeypatch.delenv("AGENT_BROWSER_PROFILE", raising=False)
    # Unpinned, the ubuntu runner (non-root, userns-restricted) flips executable() to
    # its own /usr/bin/google-chrome; host policy is not the subject here.
    monkeypatch.setattr(browser, "_userns_restricted", lambda: False)

    agent_env = browser.env_for_agent({"AGENT_BROWSER_EXECUTABLE_PATH": str(shell)})
    dock_exe, _ = browser.dock_launch()
    assert agent_env["AGENT_BROWSER_EXECUTABLE_PATH"] == dock_exe == str(headed), \
        "the agent and the dock must share one binary once a screen is up"


def test_a_real_user_pin_is_still_honoured(tmp_path, monkeypatch):
    """Only a headless-shell pin is overridden; a human's own headed browser stays put."""
    mine = tmp_path / "my-chrome"
    mine.write_text("#!/bin/sh\n", encoding="utf-8")
    mine.chmod(0o755)
    other = tmp_path / "chrome"
    other.write_text("#!/bin/sh\n", encoding="utf-8")
    other.chmod(0o755)
    monkeypatch.setattr(runtime, "state_dir", lambda: tmp_path / "bot-desktop")
    monkeypatch.setattr(browser, "_managed_executable", lambda: str(other))

    env = browser.env_for_agent({"AGENT_BROWSER_EXECUTABLE_PATH": str(mine)})
    assert env["AGENT_BROWSER_EXECUTABLE_PATH"] == str(mine)
