"""ORCH-101 (TC-06/TC-07/TC-08, AC-2/AC-5/AC-6): structural checks of the
infra-file parametrization — docker-compose.yml interpolation, Dockerfile ARGs,
deploy-hook env-override and .env.example completeness.

Every ${VAR:-default} default must equal the previous production value, so an
empty environment resolves byte-for-byte to the pre-ORCH-101 configuration
(BR-5 zero-regression). The ORCH-040 group (uid/gid/HOME/mount targets/
useradd) must move as ONE coherent set of variables, and the docker-gid
group_add («МИНА 1») must stay on all three services.
"""

import re
from pathlib import Path

import yaml

REPO_ROOT = Path(__file__).resolve().parents[1]
COMPOSE = REPO_ROOT / "docker-compose.yml"
DOCKERFILE = REPO_ROOT / "Dockerfile"
HOOK = REPO_ROOT / "scripts/orchestrator-deploy-hook.sh"
ENV_EXAMPLE = REPO_ROOT / ".env.example"

# The normative interpolation map (ADR-001 D6): variable -> default that MUST
# equal the previous hardcoded production value.
EXPECTED_DEFAULTS = {
    "ORCH_HOST_REPOS_DIR": "/home/slin/repos",
    "ORCH_HOST_CLAUDE_DIR": "/home/slin/.claude",
    "ORCH_HOST_CLAUDE_JSON": "/home/slin/.claude.json",
    "ORCH_HOST_SSH_DIR": "/home/slin/.orchestrator-ssh",
    "ORCH_AGENT_HOME_DIR": "/home/slin",
    "ORCH_HOST_CLAUDE_CODE_DIR": "/usr/lib/node_modules/@anthropic-ai/claude-code",
    "ORCH_HOST_NODE_BIN": "/usr/bin/node",
    "ORCH_DOCKER_GID": "999",
    "ORCH_RUN_UID": "1000",
    "ORCH_RUN_GID": "1000",
    "ORCH_DEPLOY_SSH_USER": "slin",
    "ORCH_DEPLOY_HOST_REPO_PATH": "/home/slin/repos/orchestrator",
    "DEPLOY_HOOK_SCRIPT": "/home/slin/bin/enduro-deploy-hook.sh",
    "ORCH_STAGING_PORT": "8501",
    "ORCH_DEPLOY_PROD_TARGET_PORT": "8500",
}

_INTERP_RE = re.compile(r"\$\{([A-Z0-9_]+):-([^}]*)\}")


def _compose_raw() -> str:
    return COMPOSE.read_text(encoding="utf-8")


def _compose_code_lines() -> list[tuple[int, str]]:
    """Compose lines with comment-only lines dropped (comments are prose, not
    configuration — the interpolation contract is judged on values)."""
    out = []
    for lineno, line in enumerate(_compose_raw().splitlines(), 1):
        if line.strip().startswith("#"):
            continue
        out.append((lineno, line))
    return out


def _compose_services() -> dict:
    return yaml.safe_load(_compose_raw())["services"]


# ---------------------------------------------------------------------------
# TC-06: compose interpolation + defaults == current values + ORCH-040 group.
# ---------------------------------------------------------------------------
def test_compose_interpolation_defaults_match_production_values():
    found: dict[str, set[str]] = {}
    for _, line in _compose_code_lines():
        for var, default in _INTERP_RE.findall(line):
            found.setdefault(var, set()).add(default)
    # Every expected variable interpolates somewhere, with EXACTLY the expected
    # default, and consistently (one default per variable across the file).
    for var, expected in EXPECTED_DEFAULTS.items():
        assert var in found, f"{var} is not interpolated in docker-compose.yml"
        assert found[var] == {expected}, (
            f"{var}: defaults {found[var]} != expected {{{expected!r}}}"
        )
    # No stray interpolations outside the normative map (single point of truth).
    unknown = set(found) - set(EXPECTED_DEFAULTS)
    assert not unknown, f"unmapped compose variables: {unknown}"


def test_compose_no_raw_host_paths_outside_interpolation_defaults():
    """Registry B closed: after stripping ${VAR:-default} expressions and
    comments, no /home/slin (or node/claude-code host path) literal remains."""
    leftovers = []
    for lineno, line in _compose_code_lines():
        code = _INTERP_RE.sub("", line)
        # NB: /usr/bin/node is NOT a needle — the mount TARGET inside the
        # container is a layout convention and legitimately stays literal.
        for needle in ("/home/slin", "/usr/lib/node_modules"):
            if needle in code:
                leftovers.append(f"{lineno}: {line.strip()}")
    assert not leftovers, "raw host paths left in compose:\n" + "\n".join(leftovers)


def test_compose_group_add_docker_gid_on_all_three_services():
    """ORCH-040 «МИНА 1»: group_add stays on every service, parametrised."""
    services = _compose_services()
    assert set(services) == {"orchestrator", "orchestrator-watchdog", "orchestrator-staging"}
    for name, svc in services.items():
        group_add = svc.get("group_add")
        assert group_add == ["${ORCH_DOCKER_GID:-999}"], (
            f"{name}: group_add must carry the parametrised docker gid, got {group_add}"
        )


def test_compose_uid_gid_home_move_as_one_group():
    """ORCH-040 coherence: runtime user:, build args and mount targets read the
    SAME variables, so uid/gid/HOME can only move together."""
    services = _compose_services()
    for name in ("orchestrator", "orchestrator-staging"):
        svc = services[name]
        assert svc["user"] == "${ORCH_RUN_UID:-1000}:${ORCH_RUN_GID:-1000}", name
        args = svc["build"]["args"]
        assert args["APP_UID"] == "${ORCH_RUN_UID:-1000}", name
        assert args["APP_GID"] == "${ORCH_RUN_GID:-1000}", name
        assert args["APP_HOME"] == "${ORCH_AGENT_HOME_DIR:-/home/slin}", name
        volumes = svc["volumes"]
        home = "${ORCH_AGENT_HOME_DIR:-/home/slin}"
        assert f"${{ORCH_HOST_CLAUDE_DIR:-/home/slin/.claude}}:{home}/.claude" in volumes, name
        assert f"${{ORCH_HOST_SSH_DIR:-/home/slin/.orchestrator-ssh}}:{home}/.ssh:ro" in volumes, name


def test_compose_ports_parametrised_with_current_defaults():
    services = _compose_services()
    prod_cmd = services["orchestrator"]["command"]
    staging_cmd = services["orchestrator-staging"]["command"]
    # D5: prod reuses the existing ORCH_DEPLOY_PROD_TARGET_PORT (one truth);
    # D4: staging reuses the Settings-shared ORCH_STAGING_PORT.
    assert prod_cmd[-1] == "${ORCH_DEPLOY_PROD_TARGET_PORT:-8500}"
    assert staging_cmd[-1] == "${ORCH_STAGING_PORT:-8501}"
    assert prod_cmd[:2] == ["uvicorn", "src.main:app"]
    assert staging_cmd[:2] == ["uvicorn", "src.main:app"]


def test_compose_container_layout_paths_stay_constant():
    """Container-side paths are a layout convention, not host values (D6)."""
    services = _compose_services()
    for name in ("orchestrator", "orchestrator-staging"):
        volumes = services[name]["volumes"]
        assert any(v.endswith(":/repos") for v in volumes), name
        assert any(v.endswith(":/opt/claude-code:ro") for v in volumes), name
        env = services[name]["environment"]
        assert "ORCH_REPOS_DIR=/repos" in env, name


# ---------------------------------------------------------------------------
# TC-07: Dockerfile — useradd via ARG with production defaults; CMD untouched.
# ---------------------------------------------------------------------------
def test_dockerfile_useradd_parametrised_via_args():
    text = DOCKERFILE.read_text(encoding="utf-8")
    assert "ARG APP_UID=1000" in text
    assert "ARG APP_GID=1000" in text
    assert "ARG APP_USER=slin" in text
    assert "ARG APP_HOME=/home/slin" in text
    useradd = next(
        line for line in text.splitlines()
        if line.startswith("RUN") and "useradd" in line
    )
    for ref in ("${APP_UID}", "${APP_GID}", "${APP_HOME}", "${APP_USER}"):
        assert ref in useradd, f"useradd does not use {ref}"
    assert "/home/slin" not in useradd  # the literal moved into the ARG default


def test_dockerfile_cmd_stays_exec_form_8500():
    """ADR-001 D5: CMD keeps the documented exec-form 8500 default (PID-1 /
    signal semantics of init:true + exec-form preserved); the prod port is
    parametrised on the compose layer instead."""
    text = DOCKERFILE.read_text(encoding="utf-8")
    assert 'CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"]' in text


# ---------------------------------------------------------------------------
# TC-08: deploy-hook env-override + .env.example completeness.
# ---------------------------------------------------------------------------
def test_deploy_hook_repo_is_env_overridable():
    text = HOOK.read_text(encoding="utf-8")
    assert 'REPO="${REPO:-/home/slin/repos/orchestrator}"' in text
    # The old unconditional assignment is gone.
    assert "\nREPO=/home/slin/repos/orchestrator\n" not in text


def _env_example_keys() -> dict[str, str]:
    keys: dict[str, str] = {}
    for line in ENV_EXAMPLE.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        keys[k.strip()] = v.strip()
    return keys


# Keys an operator must see when provisioning a new host: the new ORCH-101
# parametrization keys + the start-mandatory identity/secret keys (FR-4.4).
NEW_KEYS = (
    "ORCH_AGENT_HOME_DIR",
    "ORCH_AGENT_GIT_NAME",
    "ORCH_GIT_EMAIL_DOMAIN",
    "ORCH_STAGING_PORT",
    "ORCH_HOST_REPOS_DIR",
    "ORCH_HOST_CLAUDE_DIR",
    "ORCH_HOST_CLAUDE_JSON",
    "ORCH_HOST_SSH_DIR",
    "ORCH_HOST_CLAUDE_CODE_DIR",
    "ORCH_HOST_NODE_BIN",
    "ORCH_DOCKER_GID",
    "ORCH_RUN_UID",
    "ORCH_RUN_GID",
    "ORCH_DEPLOY_PROD_TARGET_PORT",  # pre-existing, reused by compose command:
)
START_MANDATORY_KEYS = (
    "ORCH_PLANE_API_URL",
    "ORCH_PLANE_API_TOKEN",
    "ORCH_PLANE_WORKSPACE_SLUG",
    "ORCH_PLANE_WEBHOOK_SECRET",
    "ORCH_GITEA_URL",
    "ORCH_GITEA_PUBLIC_URL",
    "ORCH_GITEA_TOKEN",
    "ORCH_GITEA_WEBHOOK_SECRET",
    "ORCH_GITEA_OWNER",
    "ORCH_TELEGRAM_BOT_TOKEN",
    "ORCH_TELEGRAM_CHAT_ID",
    "ORCH_PROJECTS_JSON",
)
SECRET_KEYS = (
    "ORCH_PLANE_API_TOKEN",
    "ORCH_PLANE_WEBHOOK_SECRET",
    "ORCH_GITEA_TOKEN",
    "ORCH_GITEA_WEBHOOK_SECRET",
    "ORCH_TELEGRAM_BOT_TOKEN",
    "WATCHDOG_TG_BOT_TOKEN",
)


def test_env_example_contains_all_new_keys():
    keys = _env_example_keys()
    missing = [k for k in NEW_KEYS if k not in keys]
    assert not missing, f".env.example is missing new ORCH-101 keys: {missing}"
    # Defaults documented in .env.example match the compose interpolation map.
    for var, expected in EXPECTED_DEFAULTS.items():
        if var in keys and var not in ("DEPLOY_HOOK_SCRIPT",):
            assert keys[var] in ("", expected), (
                f".env.example {var}={keys[var]!r} contradicts default {expected!r}"
            )


def test_env_example_contains_start_mandatory_keys():
    keys = _env_example_keys()
    missing = [k for k in START_MANDATORY_KEYS if k not in keys]
    assert not missing, f".env.example is missing start-mandatory keys: {missing}"


def test_env_example_secrets_are_placeholders_only():
    keys = _env_example_keys()
    for k in SECRET_KEYS:
        value = keys.get(k, "")
        assert value == "", f"{k} must be an empty placeholder in git, got {value!r}"
