"""Node discovery reads PM state; legacy home-local trees are never activated."""

import hashlib
import json
from pathlib import Path
import shutil
import subprocess

import pytest

import hermes_constants
import pm
from pm import paths
from pm.lock import Facts, Lockfile
from pm.package import Runner
from pm.registry import get_package
from pm.store import current_target, tree_digest

_REAL_HERMES_HOME = Path.home() / ".hermes"  # Captured before per-test HOME isolation.


def _register_installed_tool(name, executable, companions=()):
    executable = Path(executable)
    version = subprocess.run([str(executable), "--version"], capture_output=True, text=True,
                             check=True, timeout=10).stdout.strip().removeprefix("v")
    package, target, store = get_package(name), current_target(), paths.store_root()
    entry = store / package.store_entry(version, target)
    binary = package.binary(entry, target)
    assert binary is not None
    binary.parent.mkdir(parents=True)
    binary.symlink_to(executable)
    for companion in companions:
        binary.with_name(companion.name).symlink_to(companion)
    digest = hashlib.sha256(executable.read_bytes()).hexdigest()
    lock = Lockfile(paths.lockfile_path())
    lock.set_pin(name, version, {target: {"url": executable.as_uri(), "sha256": digest}})
    lock.save()
    Facts(paths.facts_path()).record(name, version, entry.name, package.env(entry, target), store,
                                    target=target, artifacts=[digest], digest=tree_digest(entry))
    return binary


@pytest.fixture
def node_store(tmp_path, monkeypatch):
    node = shutil.which("node")
    if node is None:
        pytest.skip("requires an already-installed Node executable")
    # The fixture hashes its input. Never read the developer's live PM store to
    # fabricate a temporary one; CI's external Node still exercises this path.
    if Path(node).absolute().is_relative_to(_REAL_HERMES_HOME):
        pytest.skip("requires a Node binary outside the real Hermes home")
    monkeypatch.setenv("PATH", str(Path(node).parent))
    home = tmp_path / "home"
    home.mkdir()
    store = home / "tools"
    monkeypatch.setenv("HERMES_HOME", str(home))
    monkeypatch.setenv("HERMES_RUNTIME_DIR", str(store))
    monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
    lock_path = tmp_path / "lock.json"
    monkeypatch.setattr(paths, "lockfile_path", lambda: lock_path)
    binary = _register_installed_tool("node", node)
    return home, binary, node


@pytest.mark.platforms("posix")
def test_pm_node_wins_over_legacy_tree_and_composes_child_environment(node_store, monkeypatch):
    home, binary, external = node_store
    legacy = home / "node" / "bin" / "node"
    legacy.parent.mkdir(parents=True)
    legacy.symlink_to(external)
    monkeypatch.setenv("PATH", str(Path(external).parent))
    before = paths.facts_path().read_bytes()

    selected = pm.installed_package("node")
    assert selected is not None and selected.binary == binary
    resolved = hermes_constants.find_node_executable("node")
    assert resolved is not None and resolved == str(binary)
    base = {"PATH": str(Path(external).parent), "CALLER_VALUE": "preserved"}
    environment = hermes_constants.with_hermes_node_path(base)
    assert environment == pm.env_for("npm", base_env=base)
    assert shutil.which("node", path=environment["PATH"]) == str(binary)
    child = subprocess.run(
        [resolved, "-p", "process.version"],
        env=environment, capture_output=True, text=True, check=True, timeout=10,
    )
    assert child.stdout.strip().removeprefix("v") == selected.version
    assert paths.facts_path().read_bytes() == before
    assert legacy.is_file()
    assert base == {"PATH": str(Path(external).parent), "CALLER_VALUE": "preserved"}


@pytest.mark.platforms("posix")
def test_passive_discovery_never_installs_or_repairs_a_legacy_tree(tmp_path, monkeypatch):
    home = tmp_path / "home"
    legacy = home / "node" / "bin" / "node"
    legacy.parent.mkdir(parents=True)
    legacy.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
    legacy.chmod(0o755)
    monkeypatch.setenv("HERMES_HOME", str(home))
    monkeypatch.setenv("HERMES_RUNTIME_DIR", str(home / "tools"))
    monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
    monkeypatch.setenv("PATH", "")
    before = {p.relative_to(home): p.read_bytes() for p in home.rglob("*") if p.is_file()}
    attempts = []

    def forbidden_install(*args, **kwargs):
        attempts.append((args, kwargs))
        raise AssertionError("passive lookup must not provision anything")

    monkeypatch.setattr(pm, "ensure", forbidden_install)
    assert hermes_constants.find_node_executable("node") is None
    assert hermes_constants.find_node_executable("npm") is None
    assert hermes_constants.with_hermes_node_path({"PATH": ""}) == {"PATH": ""}
    assert attempts == []
    assert {p.relative_to(home): p.read_bytes() for p in home.rglob("*") if p.is_file()} == before
    assert not (home / "tools").exists()

    # A user-owned PATH toolchain is never selected: Hermes runs only PM's copy.
    external = tmp_path / "external"
    external.mkdir()
    npm = external / "npm"
    npm.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
    npm.chmod(0o755)
    monkeypatch.setenv("PATH", str(external))
    assert hermes_constants.find_node_executable("npm") is None
    assert attempts == []
    assert not (home / "tools").exists()


@pytest.mark.platforms("posix")
def test_explicit_node_path_is_not_replaced_by_managed_name(node_store):
    _home, _binary, external = node_store
    assert hermes_constants.find_node_executable(external) == external
    assert hermes_constants.find_node_executable(str(Path(external).with_name("missing-node"))) is None
    assert hermes_constants.find_node_executable("not-a-node-command") is None


@pytest.mark.platforms("posix")
def test_npm_and_npx_use_the_paired_pm_entry(node_store, monkeypatch):
    home, node, external = node_store
    npm = Path(external).with_name("npm")
    npx = Path(external).with_name("npx")
    if not npm.is_file() or not npx.is_file():
        pytest.skip("requires already-installed npm and npx")
    binary = _register_installed_tool("npm", npm, [npx])
    companion = binary.with_name("npx")
    legacy = home / "node" / "bin" / "npm"
    legacy.parent.mkdir(parents=True)
    legacy.symlink_to(npm)
    before = paths.facts_path().read_bytes()
    monkeypatch.setenv("PATH", "")

    assert hermes_constants.find_node_executable("npm") == str(binary)
    assert hermes_constants.find_node_executable("npx") == str(companion)
    environment = hermes_constants.with_hermes_node_path({"PATH": ""})
    assert shutil.which("npm", path=environment["PATH"]) == str(binary)
    assert shutil.which("node", path=environment["PATH"]) == str(node)
    for command in ("npm", "npx"):
        resolved = hermes_constants.find_node_executable(command)
        assert resolved is not None
        result = subprocess.run(
            [resolved, "--version"], env=environment,
            capture_output=True, text=True, check=True, timeout=10,
        )
        assert result.stdout.strip() == Lockfile(paths.lockfile_path()).version("npm")
    assert paths.facts_path().read_bytes() == before
    companion.unlink()
    assert hermes_constants.find_node_executable("npx") is None


@pytest.mark.platforms("windows")
def test_windows_path_prefers_launchable_cmd_but_never_for_pm_tools(tmp_path, monkeypatch):
    monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "absent-store"))
    monkeypatch.setenv("PATH", str(tmp_path))
    for tool in ("npm", "pnpm"):
        for name in (tool, f"{tool}.ps1", f"{tool}.cmd"):
            (tmp_path / name).write_text("@exit /b 0\n", encoding="utf-8")
    # pnpm is an explicit user choice (lsp.package_manager); npm is only ever PM's.
    assert hermes_constants.find_node_executable("pnpm") == str(tmp_path / "pnpm.cmd")
    assert hermes_constants.find_node_executable("npm") is None


@pytest.fixture
def npm_probe(node_store, monkeypatch):
    home, node, _external = node_store
    store = paths.store_root()
    package = get_package("npm")
    target = current_target()
    entry = store / package.store_entry("1.0.0", target)
    binary = package.binary(entry, target)
    assert binary is not None
    binary.parent.mkdir(parents=True)
    binary.write_text(
        '#!/usr/bin/env node\n'
        'const fs = require("fs"); const path = require("path");\n'
        'const i = process.argv.indexOf("--prefix");\n'
        'const root = i < 0 ? process.cwd() : process.argv[i + 1];\n'
        'fs.mkdirSync(path.join(root, "node_modules/.bin"), {recursive:true});\n'
        'fs.writeFileSync(path.join(root, "node_modules/.bin/test-server"), "ready");\n'
        'fs.writeFileSync(path.join(root, "called.json"), JSON.stringify({argv: process.argv, env: process.env}));\n',
        encoding="utf-8",
    )
    binary.chmod(0o755)
    lock = Lockfile(paths.lockfile_path())
    lock.set_pin("npm", "1.0.0", {})
    lock.save()

    def publish():
        Facts(paths.facts_path()).record(
            "npm", "1.0.0", entry.name, package.env(entry, target), store,
        )
        return Runner("npm", pm.env_for("npm"))

    monkeypatch.setenv("PATH", "")
    return home, node, binary, publish


@pytest.fixture
def npm_consumers(npm_probe, tmp_path, monkeypatch):
    from agent.lsp.install import _install_npm
    from gateway.config import PlatformConfig
    from hermes_cli.main_platform_setup import _whatsapp_install_bridge
    from hermes_cli.web_routers.messaging import _ensure_whatsapp_bridge_dependencies
    from plugins.platforms.photon import adapter as photon, cli
    from plugins.platforms.whatsapp.adapter import WhatsAppAdapter

    home, _node, _binary, _publish = npm_probe
    bridge = tmp_path / "bridge"
    bridge.mkdir()
    (bridge / "package.json").write_text('{"name":"test-bridge"}', encoding="utf-8")
    adapter = WhatsAppAdapter(PlatformConfig(extra={"bridge_script": str(bridge / "bridge.js")}))
    monkeypatch.setattr(photon, "_sidecar_dir", lambda: bridge)
    monkeypatch.setattr(cli, "_sidecar_dir", lambda: bridge)
    return {
        "lsp": (lambda: _install_npm("test-pkg", "test-server"), home / "lsp"),
        "whatsapp": (lambda: adapter._ensure_bridge_deps(bridge), bridge),
        "photon": (photon._reinstall_sidecar_deps, bridge),
        "photon-cli": (cli._install_sidecar, bridge),
        "cli": (lambda: _whatsapp_install_bridge(bridge), bridge),
        "dashboard": (lambda: _ensure_whatsapp_bridge_dependencies(bridge), bridge),
    }


@pytest.mark.platforms("posix")
@pytest.mark.parametrize("consumer", ["lsp", "whatsapp", "photon", "photon-cli", "cli", "dashboard"])
def test_npm_consumers_execute_with_pm_node(npm_probe, npm_consumers, consumer):
    _home, node, binary, publish = npm_probe
    publish()
    call, output_dir = npm_consumers[consumer]
    call()
    result = json.loads((output_dir / "called.json").read_text())
    assert Path(result["argv"][1]) == binary
    assert shutil.which("node", path=result["env"]["PATH"]) == str(node)
    if consumer == "lsp":
        assert result["argv"][2:] == [
            "install", "--prefix", str(output_dir), "--silent", "--no-fund", "--no-audit", "test-pkg",
        ]


@pytest.mark.platforms("posix")
@pytest.mark.parametrize("surface", ["cli", "dashboard", "whatsapp", "photon", "photon-cli"])
def test_missing_npm_acquires_npm_closure_at_install_boundary(npm_probe, npm_consumers, monkeypatch, surface):
    _home, node, binary, publish = npm_probe
    calls = []

    def ensure(name, **kwargs):
        calls.append((name, kwargs.get("explicit", False)))
        assert name == "npm"
        return publish()

    monkeypatch.setattr(pm, "ensure", ensure)
    call, bridge = npm_consumers[surface]
    call()
    assert calls == [("npm", surface in {"cli", "dashboard", "photon-cli"})]
    result = json.loads((bridge / "called.json").read_text())
    assert Path(result["argv"][1]) == binary
    assert shutil.which("node", path=result["env"]["PATH"]) == str(node)


@pytest.mark.platforms("posix")
@pytest.mark.parametrize("surface", ["whatsapp", "photon"])
def test_runtime_npm_refusal_never_falls_back_to_literal_npm(npm_probe, npm_consumers, monkeypatch, surface):
    call, bridge = npm_consumers[surface]
    spawns = []
    run = subprocess.run

    def forbidden_spawn(*args, **kwargs):
        if args[0][0] == "git":  # Fatal-status reporting may read the code revision.
            return run(*args, **kwargs)
        spawns.append(args)
        raise AssertionError("refused PM preparation must not spawn npm")

    monkeypatch.setattr(subprocess, "run", forbidden_spawn)
    call()
    assert spawns == []
    assert not (bridge / "called.json").exists()
    assert pm.installed_package("npm") is None


@pytest.mark.parametrize("allowed", [False, True])
def test_adapter_availability_never_provisions_missing_node(tmp_path, monkeypatch, allowed):
    from plugins.platforms.photon import adapter as photon
    from plugins.platforms.whatsapp import adapter as whatsapp

    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "missing-tools"))
    monkeypatch.setenv("PATH", "")
    monkeypatch.delenv("PHOTON_NODE_BIN", raising=False)
    monkeypatch.setattr(photon, "_sidecar_dir", lambda: tmp_path)
    monkeypatch.setattr(photon, "HTTPX_AVAILABLE", True)
    monkeypatch.setattr(pm, "lazy_installs_allowed", lambda: allowed)
    installs = []

    def forbidden_ensure(*args, **kwargs):
        installs.append(args)
        raise AssertionError("availability must not install")

    monkeypatch.setattr(pm, "ensure", forbidden_ensure)
    assert whatsapp.check_whatsapp_requirements() is allowed
    assert photon.check_requirements() is allowed
    assert installs == []
    assert not (tmp_path / "missing-tools").exists()


@pytest.mark.platforms("posix")
def test_dashboard_pairing_prepares_npm_before_node_lookup(npm_probe, tmp_path, monkeypatch):
    from gateway.platforms import whatsapp_common
    from hermes_cli.web_routers.messaging import _spawn_whatsapp_pairing_process

    _home, node, _npm, publish = npm_probe
    node_facts = paths.facts_path().read_bytes()
    paths.facts_path().unlink()
    bridge = tmp_path / "bridge"
    bridge.mkdir()
    (bridge / "bridge.js").write_text('console.log(JSON.stringify({argv:process.argv, path:process.env.PATH}));\n')
    monkeypatch.setattr(whatsapp_common, "resolve_whatsapp_bridge_dir", lambda: bridge)
    installs = []

    def ensure(name, **kwargs):
        installs.append((name, kwargs.get("explicit", False)))
        paths.facts_path().write_bytes(node_facts)
        return publish()

    monkeypatch.setattr(pm, "ensure", ensure)
    child = _spawn_whatsapp_pairing_process(tmp_path / "session", "bot")
    try:
        output, _ = child.communicate(timeout=10)
        assert child.returncode == 0
    finally:
        if child.poll() is None:
            child.kill()
            child.wait(timeout=10)
    assert installs == [("npm", True)]
    result = json.loads(output)
    assert "--pair-json" in result["argv"]
    assert shutil.which("node", path=result["path"]) == str(node)


@pytest.mark.platforms("posix")
def test_lsp_node_server_inherits_pm_runtime_and_preserves_overrides(node_store, tmp_path, monkeypatch):
    from agent.lsp.servers import ServerContext, find_server_for_file

    _home, node, _external = node_store
    script = tmp_path / "language-server"
    script.write_text('#!/usr/bin/env node\nconsole.log(JSON.stringify({path:process.env.PATH, flag:process.env.LSP_FLAG}));\n')
    script.chmod(0o755)
    monkeypatch.setenv("PATH", "")
    ctx = ServerContext(
        workspace_root=str(tmp_path), install_strategy="off", trusted=True,
        binary_overrides={"typescript": [str(script)]},
        env_overrides={"typescript": {"LSP_FLAG": "project-value"}},
    )
    server = find_server_for_file(str(tmp_path / "test.ts"))
    assert server is not None
    spec = server.build_spawn(str(tmp_path), ctx)
    assert spec is not None
    child = subprocess.run(spec.command, env=spec.env, capture_output=True, text=True, timeout=10)
    assert child.returncode == 0, child.stderr
    observed = json.loads(child.stdout)
    assert shutil.which("node", path=observed["path"]) == str(node)
    assert observed["flag"] == "project-value"
    ctx.env_overrides["typescript"]["PATH"] = "/explicit/project/tools"
    spec = server.build_spawn(str(tmp_path), ctx)
    assert spec is not None and spec.env["PATH"] == "/explicit/project/tools"
