"""Tests for docker-compose.yml port exposure.

Covers issue #50 acceptance criteria:
- ``docker-compose.yml`` exposes port 4096 on ``0.0.0.0`` (open to the network)
  so that NPM (Nginx Proxy Manager) on a separate host can reach the opencode
  web UI via ``193.3.168.35:4096``.
- The previous ``127.0.0.1:4096:4096`` binding (localhost-only) is gone — it
  blocked cross-host NPM proxying.

PyYAML is not a direct project dependency, so the compose file is parsed
manually by tracking indentation of the ``ports:`` block (mirrors the
frontmatter parser in ``test_agent_frontmatter.py``). A raw-text assertion
is also included as a belt-and-suspenders check.
"""

from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
COMPOSE_FILE = REPO_ROOT / "docker-compose.yml"

EXPECTED_BINDING = "0.0.0.0:4096:4096"
OLD_BINDING = "127.0.0.1:4096:4096"


def _compose_text() -> str:
    """Read docker-compose.yml text (asserts the file exists)."""
    assert COMPOSE_FILE.exists(), f"docker-compose.yml missing: {COMPOSE_FILE}"
    return COMPOSE_FILE.read_text()


def _parse_ports_bindings(text: str) -> list[str]:
    """Extract port binding strings from the ``ports:`` blocks of compose YAML.

    Manual parse (no pyyaml): walk lines, detect any ``ports:`` key (it sits
    under a service at indent 4), remember its indent, then collect every
    following list entry at a strictly greater indent until the indent drops
    back to or below the ``ports:`` indent. A list entry is a line whose
    stripped form starts with ``-``.
    """
    bindings: list[str] = []
    in_ports = False
    ports_indent = -1
    for line in text.split("\n"):
        if not line.strip() or line.strip().startswith("#"):
            continue
        stripped = line.lstrip()
        indent = len(line) - len(stripped)
        if stripped.startswith("ports:") and stripped.endswith(":"):
            in_ports = True
            ports_indent = indent
            continue
        if in_ports:
            if indent <= ports_indent:
                in_ports = False
                continue
            if stripped.startswith("-"):
                value = stripped.lstrip("-").strip()
                value = value.strip('"').strip("'")
                bindings.append(value)
    return bindings


# ── file exists ─────────────────────────────────────────────────────────────


def test_docker_compose_exists():
    """docker-compose.yml exists at repo root."""
    assert COMPOSE_FILE.exists(), f"docker-compose.yml missing: {COMPOSE_FILE}"


# ── port 4096 exposed on 0.0.0.0 (issue #50 — the required binding) ──────────


def test_port_exposed_on_all_interfaces():
    """docker-compose.yml exposes 4096 on ``0.0.0.0`` (open to the network).

    NPM on a separate host must reach the opencode web UI; ``0.0.0.0:4096:4096``
    binds to all interfaces, ``127.0.0.1`` would block cross-host access.
    """
    bindings = _parse_ports_bindings(_compose_text())
    assert EXPECTED_BINDING in bindings, (
        f"docker-compose.yml must bind {EXPECTED_BINDING!r} (open to network) — "
        f"found port bindings: {bindings}"
    )


def test_port_exposed_on_all_interfaces_raw():
    """Raw text check: the compose file contains ``0.0.0.0:4096:4096``.

    Belt-and-suspenders alongside the parsed check — catches edge cases where
    the manual parser might miss a quoted or unquoted binding.
    """
    content = _compose_text()
    assert EXPECTED_BINDING in content, (
        f"docker-compose.yml must contain {EXPECTED_BINDING!r} so NPM can reach "
        "the web UI from a separate host"
    )


# ── old localhost-only binding is gone (issue #50 — must not regress) ───────


def test_no_localhost_only_binding():
    """docker-compose.yml does NOT bind ``127.0.0.1:4096:4096``.

    The localhost-only binding blocks NPM proxying from another host; it must
    not be present (and must not come back via a future regression).
    """
    bindings = _parse_ports_bindings(_compose_text())
    assert OLD_BINDING not in bindings, (
        f"docker-compose.yml must not bind {OLD_BINDING!r} (localhost-only) — "
        "NPM on a separate host cannot reach it"
    )


def test_no_localhost_only_binding_raw():
    """Raw text check: ``127.0.0.1:4096:4096`` is absent from the compose file."""
    content = _compose_text()
    assert OLD_BINDING not in content, (
        f"docker-compose.yml must not contain {OLD_BINDING!r} — the localhost-only "
        "binding blocks cross-host NPM proxying"
    )


# ── opencode resource limits increased (issue #131 — anti-regression) ───────


def test_opencode_memory_limit_is_8g():
    """opencode service memory limit is 8G (was 6G; peak hit 95% of 6G)."""
    content = _compose_text()
    assert "memory: 8G" in content, (
        "docker-compose.yml opencode limits.memory must be 8G — 6G caused "
        "direct-reclaim stalls blocking the serve event loop"
    )
    assert "memory: 6G" not in content, (
        "docker-compose.yml must not retain the old 6G opencode memory limit"
    )


def test_opencode_cpu_limit_is_4():
    """opencode service cpu limit is 4 cores (was 3; 879 throttle events observed)."""
    content = _compose_text()
    assert "cpus: '4'" in content, (
        "docker-compose.yml opencode limits.cpus must be '4' — '3' caused "
        "879 throttle events and sustained CPU pressure"
    )
    assert "cpus: '3'" not in content, (
        "docker-compose.yml must not retain the old '3' opencode cpu limit"
    )


def test_opencode_pids_limit_is_2048():
    """opencode service pids limit is 2048 (was 1024; >120 zombies observed)."""
    content = _compose_text()
    assert "pids: 2048" in content, (
        "docker-compose.yml opencode limits.pids must be 2048 — 1024 left no "
        "buffer for accumulated zombie processes"
    )
    assert "pids: 1024" not in content, (
        "docker-compose.yml must not retain the old 1024 opencode pids limit"
    )


# ── opencode init: true for zombie reaping (issue #131) ──────────────────────


def test_opencode_has_init_true():
    """opencode service enables ``init: true`` so tini reaps orphaned zombies.

    opencode runs as PID 1 and never calls ``wait()``; without an init process
    zombies accumulate (>120 observed). Docker's ``init: true`` injects tini
    as PID 1 to reap orphans.
    """
    content = _compose_text()
    assert "\n    init: true\n" in content, (
        "docker-compose.yml opencode service must set init: true — without it "
        "orphaned zombie processes accumulate (opencode is PID 1 and does not "
        "reap children)"
    )


# ── opencode healthcheck for hang auto-restart (issue #131) ──────────────────


def test_opencode_has_healthcheck_block():
    """opencode service defines a healthcheck probing localhost:4096.

    ``opencode serve`` requires basic auth and returns 401 without credentials
    — that is healthy. The check accepts 200 or 401 (server responds) and
    fails only when serve hangs (no HTTP response). Docker restarts unhealthy
    containers via the existing ``restart: unless-stopped``.
    """
    content = _compose_text()
    assert "healthcheck:" in content, (
        "docker-compose.yml opencode service must define a healthcheck — "
        "without it Docker cannot detect serve hangs to auto-restart"
    )
    assert "curl" in content and "localhost:4096" in content, (
        "healthcheck must probe opencode serve on localhost:4096 via curl"
    )
    assert "200" in content and "401" in content, (
        "healthcheck must accept both 200 and 401 as healthy — serve returns "
        "401 without basic-auth credentials"
    )


def test_opencode_healthcheck_timing():
    """healthcheck timing matches issue #131 spec (interval/timeout/retries/start)."""
    content = _compose_text()
    assert "interval: 30s" in content
    assert "timeout: 10s" in content
    assert "retries: 3" in content
    assert "start_period: 30s" in content


# ── restart not duplicated (issue #131 — must not regress) ───────────────────


def test_opencode_restart_not_duplicated():
    """``restart: unless-stopped`` appears exactly once for opencode.

    It already exists; the healthcheck relies on it for auto-restart. A
    duplicate key would be a YAML error or a silent override.
    """
    content = _compose_text()
    opencode_block = content.split("  opencode:", 1)[1]
    assert opencode_block.count("restart: unless-stopped") == 1, (
        "docker-compose.yml opencode service must have exactly one "
        "'restart: unless-stopped' (already present, must not duplicate)"
    )


# ── dind service unchanged (issue #131 — must not regress) ──────────────────


def test_dind_limits_unchanged():
    """dind service keeps its original limits (4G/2cpu/512pids) — not in scope."""
    content = _compose_text()
    dind_block = content.split("  dind:", 1)[1].split("  opencode:", 1)[0]
    assert "cpus: '2'" in dind_block
    assert "memory: 4G" in dind_block
    assert "pids: 512" in dind_block


# ── DOCKER_HOST points to a resolvable name (issue #222 — fix unresolvable host)


def test_docker_host_uses_resolvable_name():
    """opencode ``DOCKER_HOST`` points to the dind service alias ``dind``.

    The compose service is named ``dind`` (``container_name:
    opencode-docker-dind``). DNS inside the ``opencode_network`` resolves the
    service name, so ``tcp://dind:2375`` works; the old ``tcp://docker-dind``
    referenced no existing name and never resolved.
    """
    content = _compose_text()
    assert "DOCKER_HOST=tcp://dind:2375" in content, (
        "docker-compose.yml opencode DOCKER_HOST must point to `tcp://dind:2375` "
        "(compose service alias) — the old `docker-dind` name does not resolve "
        "in opencode_network (issue #222)"
    )


def test_docker_host_old_name_removed():
    """Raw text check: ``docker-dind`` hostname is absent from DOCKER_HOST.

    The old ``tcp://docker-dind:2375`` referenced neither the compose service
    name (``dind``) nor the container name (``opencode-docker-dind``) and
    never resolved inside ``opencode_network``.
    """
    content = _compose_text()
    assert "DOCKER_HOST=tcp://docker-dind:2375" not in content, (
        "docker-compose.yml must not retain the unresolvable `docker-dind` "
        "hostname in DOCKER_HOST (issue #222)"
    )


if __name__ == "__main__":
    import pytest

    pytest.main([__file__, "-v"])
