import json
import socket
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch

from armorer.apps.openclaw import OpenClawInstanceConfig, apply_openclaw_instance_config
from armorer.main import _build_runtime_env
from armorer.openclaw_config import (
    canonicalize_openclaw_mount_env_paths,
    _port_is_bindable,
    derive_openclaw_control_ui_origins,
    ensure_openclaw_runtime_bootstrap,
    ensure_openclaw_instance_ports,
    ensure_openclaw_bootstrap_config,
    OPENCLAW_RUNTIME_IMAGE_DEFAULT,
    resolve_openclaw_runtime_image,
    resolve_openclaw_registered_install_path,
    validate_openclaw_runtime_bootstrap,
)


def test_derive_openclaw_control_ui_origins_uses_custom_port_and_host_bind(tmp_path):
    (tmp_path / ".env").write_text("OPENCLAW_GATEWAY_PORT=19001\nOPENCLAW_GATEWAY_BIND=myhost.local\n")
    (tmp_path / "docker-compose.yml").write_text(
        """
services:
  openclaw-gateway:
    command:
      - node
      - dist/index.js
      - gateway
      - --bind
      - ${OPENCLAW_GATEWAY_BIND:-lan}
      - --port
      - "18789"
    ports:
      - ${OPENCLAW_GATEWAY_PORT:-18789}:18789
"""
    )

    origins = derive_openclaw_control_ui_origins(tmp_path)

    assert origins == [
        "http://127.0.0.1:19001",
        "http://localhost:19001",
        "http://[::1]:19001",
        "http://myhost.local:19001",
    ]


def test_ensure_openclaw_bootstrap_config_merges_existing_origins(tmp_path):
    (tmp_path / ".env").write_text("")
    (tmp_path / "docker-compose.yml").write_text(
        """
services:
  openclaw-gateway:
    command:
      - node
      - dist/index.js
      - gateway
      - --bind
      - ${OPENCLAW_GATEWAY_BIND:-lan}
    ports:
      - ${OPENCLAW_GATEWAY_PORT:-18789}:18789
"""
    )
    config_path = tmp_path / ".openclaw" / "openclaw.json"
    config_path.parent.mkdir(parents=True, exist_ok=True)
    config_path.write_text(
        json.dumps(
            {
                "gateway": {
                    "mode": "local",
                    "controlUi": {
                        "allowedOrigins": ["https://openclaw.example.com"]
                    },
                }
            },
            indent=2,
        )
        + "\n"
    )

    changed = ensure_openclaw_bootstrap_config(config_path, tmp_path)
    data = json.loads(config_path.read_text())

    assert changed is True
    assert data["gateway"]["controlUi"]["allowedOrigins"] == [
        "https://openclaw.example.com",
        "http://127.0.0.1:18789",
        "http://localhost:18789",
        "http://[::1]:18789",
    ]


def test_ensure_openclaw_bootstrap_config_is_idempotent(tmp_path):
    (tmp_path / ".env").write_text("")
    (tmp_path / "docker-compose.yml").write_text(
        """
services:
  openclaw-gateway:
    command:
      - node
      - dist/index.js
      - gateway
      - --bind
      - ${OPENCLAW_GATEWAY_BIND:-lan}
    ports:
      - ${OPENCLAW_GATEWAY_PORT:-18789}:18789
"""
    )
    config_path = tmp_path / ".openclaw" / "openclaw.json"

    first = ensure_openclaw_bootstrap_config(config_path, tmp_path)
    second = ensure_openclaw_bootstrap_config(config_path, tmp_path)

    assert first is True
    assert second is False


@patch("armorer.openclaw_config._docker_published_ports", return_value=set())
@patch("armorer.openclaw_config._port_is_bindable", return_value=True)
@patch("armorer.openclaw_config.AppRegistry")
def test_ensure_openclaw_instance_ports_assigns_next_free_pair(
    mock_registry_cls, mock_bindable, mock_docker_ports, tmp_path
):
    other = tmp_path.parent / "openclaw_2"
    other.mkdir()
    (other / ".env").write_text("OPENCLAW_GATEWAY_PORT=18789\nOPENCLAW_BRIDGE_PORT=18790\n")
    mock_registry = mock_registry_cls.return_value
    mock_registry.list_apps.return_value = [
        SimpleNamespace(name="openclaw_2", agent_id="openclaw", install_path=str(other))
    ]
    (tmp_path / ".env").write_text("")

    env = ensure_openclaw_instance_ports(tmp_path)

    assert env["OPENCLAW_GATEWAY_PORT"] == "18791"
    assert env["OPENCLAW_BRIDGE_PORT"] == "18792"
    saved = (tmp_path / ".env").read_text()
    assert "OPENCLAW_GATEWAY_PORT=18791" in saved
    assert "OPENCLAW_BRIDGE_PORT=18792" in saved


@patch("armorer.openclaw_config._docker_published_ports", return_value=set())
@patch("armorer.openclaw_config._port_is_bindable", return_value=True)
@patch("armorer.openclaw_config.AppRegistry")
def test_ensure_openclaw_instance_ports_preserves_existing_values(
    mock_registry_cls, mock_bindable, mock_docker_ports, tmp_path
):
    mock_registry_cls.return_value.list_apps.return_value = []
    (tmp_path / ".env").write_text("OPENCLAW_GATEWAY_PORT=19001\nOPENCLAW_BRIDGE_PORT=19002\n")

    env = ensure_openclaw_instance_ports(tmp_path)

    assert env["OPENCLAW_GATEWAY_PORT"] == "19001"
    assert env["OPENCLAW_BRIDGE_PORT"] == "19002"


@patch("armorer.openclaw_config._docker_published_ports", return_value={18789, 18790})
@patch("armorer.openclaw_config._port_is_bindable", return_value=True)
@patch("armorer.openclaw_config.AppRegistry")
def test_ensure_openclaw_instance_ports_avoids_docker_published_ports(
    mock_registry_cls, mock_bindable, mock_docker_ports, tmp_path
):
    mock_registry_cls.return_value.list_apps.return_value = []
    (tmp_path / ".env").write_text("")

    env = ensure_openclaw_instance_ports(tmp_path)

    assert env["OPENCLAW_GATEWAY_PORT"] == "18791"
    assert env["OPENCLAW_BRIDGE_PORT"] == "18792"


def test_port_is_bindable_tolerates_missing_ipv6_loopback(monkeypatch):
    class IPv6MissingSocket:
        def __init__(self, family, *args, **kwargs):
            self._family = family
            self._closed = False

        def setsockopt(self, *args, **kwargs):
            return None

        def bind(self, address):
            if self._family == socket.AF_INET6:
                raise OSError("Address family not supported by protocol")
            return None

        def close(self):
            self._closed = True

    monkeypatch.setattr("armorer.openclaw_config.socket.socket", IPv6MissingSocket)

    assert _port_is_bindable(19889) is True


def test_resolve_openclaw_registered_install_path_prefers_host_visible_dir(tmp_path, monkeypatch):
    project_root = tmp_path / "project"
    host_install = project_root / "installed" / "openclaw_2"
    host_install.mkdir(parents=True)

    monkeypatch.setenv("HOST_PROJECT_ROOT", str(project_root))
    monkeypatch.setattr("armorer.openclaw_config.os.path.exists", lambda p: p == "/.dockerenv")

    resolved = resolve_openclaw_registered_install_path("/app/installed/openclaw_2")

    assert resolved == host_install.resolve()


def test_ensure_openclaw_runtime_bootstrap_creates_host_visible_runtime_files(tmp_path):
    (tmp_path / "docker-compose.yml").write_text(
        """
services:
  openclaw-gateway:
    command:
      - node
      - dist/index.js
      - gateway
      - --bind
      - ${OPENCLAW_GATEWAY_BIND:-lan}
    ports:
      - ${OPENCLAW_GATEWAY_PORT:-18789}:18789
"""
    )

    runtime = ensure_openclaw_runtime_bootstrap(tmp_path)

    assert runtime["config_dir"] == (tmp_path / ".openclaw")
    assert runtime["workspace_dir"] == (tmp_path / "workspace")
    assert runtime["config_path"].exists()
    assert runtime["runtime_env_path"].exists()
    assert validate_openclaw_runtime_bootstrap(tmp_path) == []
    env_text = (tmp_path / ".env").read_text()
    assert f"OPENCLAW_CONFIG_DIR={(tmp_path / '.openclaw').resolve()}" in env_text
    assert f"OPENCLAW_WORKSPACE_DIR={(tmp_path / 'workspace').resolve()}" in env_text

    data = json.loads(runtime["config_path"].read_text())
    assert data["gateway"]["mode"] == "local"
    assert data["gateway"]["controlUi"]["allowedOrigins"]


def test_canonicalize_openclaw_mount_env_paths_uses_host_visible_runtime_dir(tmp_path, monkeypatch):
    host_project = tmp_path / "host-project"
    host_install = host_project / "installed" / "openclaw"
    host_install.mkdir(parents=True)

    monkeypatch.setenv("HOST_PROJECT_ROOT", str(host_project))
    monkeypatch.setattr("armorer.openclaw_config.os.path.exists", lambda p: p == "/.dockerenv")

    env, changed = canonicalize_openclaw_mount_env_paths(Path("/app/installed/openclaw"), {})

    assert changed is True
    assert env["OPENCLAW_CONFIG_DIR"] == str((host_install / ".openclaw").resolve())
    assert env["OPENCLAW_WORKSPACE_DIR"] == str((host_install / "workspace").resolve())


@patch("armorer.apps.openclaw.config_adapter.ensure_openclaw_instance_ports")
def test_apply_openclaw_instance_config_persists_absolute_mount_paths(mock_ports, tmp_path):
    (tmp_path / ".env").write_text("OPENCLAW_CONFIG_DIR=./.openclaw\nOPENCLAW_WORKSPACE_DIR=./workspace\n")
    cfg = OpenClawInstanceConfig(
        access_mode="local",
        primary_provider="gemini",
        default_model="gemini-3.1-flash-lite-preview",
        enabled_channels=["telegram"],
        enabled_skills=[],
        trusted_proxies=[],
        skill_settings={},
        web_search_provider="disabled",
    )

    apply_openclaw_instance_config(
        tmp_path,
        cfg,
        secrets_map={"GEMINI_API_KEY": "gm-test", "TELEGRAM_BOT_TOKEN": "tg-test"},
    )

    env_text = (tmp_path / ".env").read_text()
    assert f"OPENCLAW_CONFIG_DIR={(tmp_path / '.openclaw').resolve()}" in env_text
    assert f"OPENCLAW_WORKSPACE_DIR={(tmp_path / 'workspace').resolve()}" in env_text


def test_apply_openclaw_instance_config_syncs_gateway_token_into_bootstrap_config(tmp_path):
    token = "1234567890abcdef1234567890abcdef"
    (tmp_path / ".env").write_text(f"OPENCLAW_GATEWAY_TOKEN={token}\n")
    config_path = tmp_path / ".openclaw" / "openclaw.json"
    config_path.parent.mkdir(parents=True, exist_ok=True)
    config_path.write_text(
        json.dumps(
            {
                "gateway": {
                    "auth": {"mode": "token", "token": "stale-token-value-0001"},
                    "remote": {"token": "stale-token-value-0001"},
                }
            }
        )
        + "\n"
    )
    cfg = OpenClawInstanceConfig(
        access_mode="local",
        primary_provider="gemini",
        default_model="gemini-3.1-flash-lite-preview",
        enabled_channels=["telegram"],
        enabled_skills=[],
        trusted_proxies=[],
        skill_settings={},
        web_search_provider="disabled",
    )

    apply_openclaw_instance_config(
        tmp_path,
        cfg,
        secrets_map={"GEMINI_API_KEY": "gm-test", "TELEGRAM_BOT_TOKEN": "tg-test"},
    )

    data = json.loads(config_path.read_text())
    assert data["gateway"]["mode"] == "local"
    assert data["gateway"]["auth"]["mode"] == "token"
    assert data["gateway"]["auth"]["token"] == token
    assert data["gateway"]["remote"]["token"] == token


def test_build_runtime_env_absolutizes_mount_paths_outside_docker(tmp_path, monkeypatch):
    (tmp_path / ".env").write_text("OPENCLAW_CONFIG_DIR=./.openclaw\nOPENCLAW_WORKSPACE_DIR=./workspace\n")
    monkeypatch.setattr("armorer.main.os.path.exists", lambda p: False)

    env = _build_runtime_env(tmp_path)

    assert env["OPENCLAW_CONFIG_DIR"] == str((tmp_path / ".openclaw").resolve())
    assert env["OPENCLAW_WORKSPACE_DIR"] == str((tmp_path / "workspace").resolve())


def test_build_runtime_env_tolerates_platforms_without_uid_gid(tmp_path, monkeypatch):
    (tmp_path / ".env").write_text("OPENCLAW_CONFIG_DIR=./.openclaw\nOPENCLAW_WORKSPACE_DIR=./workspace\n")
    monkeypatch.setattr("armorer.main.os.path.exists", lambda p: False)
    monkeypatch.delattr("armorer.main.os.getuid", raising=False)
    monkeypatch.delattr("armorer.main.os.getgid", raising=False)

    env = _build_runtime_env(tmp_path)

    assert "UID" not in env
    assert "GID" not in env
    assert env["OPENCLAW_CONFIG_DIR"] == str((tmp_path / ".openclaw").resolve())
    assert env["OPENCLAW_WORKSPACE_DIR"] == str((tmp_path / "workspace").resolve())


def test_resolve_openclaw_runtime_image_uses_legacy_and_custom_fallbacks():
    with patch("armorer.openclaw_config._openclaw_local_image_exists", return_value=False):
        assert resolve_openclaw_runtime_image({"OPENCLAW_IMAGE": ""}) == OPENCLAW_RUNTIME_IMAGE_DEFAULT
        assert resolve_openclaw_runtime_image({}, fallback="registry.local/openclaw:dev") == "registry.local/openclaw:dev"
    assert resolve_openclaw_runtime_image({"OPENCLAW_IMAGE": "openclaw:local"}) == "openclaw:local"
    assert resolve_openclaw_runtime_image({"OPENCLAW_IMAGE": "ghcr.io/openclaw/openclaw:edge"}) == "ghcr.io/openclaw/openclaw:edge"
    with patch("armorer.openclaw_config._openclaw_local_image_exists", return_value=True):
        assert resolve_openclaw_runtime_image({"OPENCLAW_IMAGE": ""}) == "openclaw:local"
