"""Dockerfile + compose-config smoke tests for SP23.

These tests do NOT require a running Docker daemon (the compose-up test
is gated on ``DOCKER_TESTS=1`` so it can be skipped on bare CI without
Docker). They validate that:

* ``docker-compose.yml`` at the repo root is syntactically valid and that
  the shape we expect (services, secrets, volumes, networks) is present.
* Both Dockerfiles parse with ``docker build --check`` if Docker is on PATH.
* The named-volume mount paths match what ``cyclone.db`` + the BackupService
  expect at runtime.
"""
from __future__ import annotations

import os
import shutil
import subprocess
from pathlib import Path

import pytest
import yaml

REPO_ROOT = Path(__file__).resolve().parents[2]
COMPOSE_FILE = REPO_ROOT / "docker-compose.yml"
BACKEND_DOCKERFILE = REPO_ROOT / "backend" / "Dockerfile"
FRONTEND_DOCKERFILE = REPO_ROOT / "Dockerfile.frontend"
FRONTEND_NGINX_CONF = REPO_ROOT / "nginx.conf"


def _has_docker() -> bool:
    return shutil.which("docker") is not None


def _has_docker_compose() -> bool:
    if shutil.which("docker") is None:
        return False
    return (
        subprocess.run(
            ["docker", "compose", "version"],
            capture_output=True,
            check=False,
        ).returncode
        == 0
    )


def _volume_source(v) -> str:
    """Normalize a compose volume entry to the source name (str form or 'source' key)."""
    if isinstance(v, str):
        # Long form: "named_volume:/container/path" — split on ':'.
        return v.split(":", 1)[0]
    if isinstance(v, dict):
        return v.get("source", "")
    return ""


def test_compose_file_exists():
    assert COMPOSE_FILE.exists(), f"missing {COMPOSE_FILE}"


def test_compose_config_validates():
    """``docker compose config`` should exit 0 with no stderr."""
    if not _has_docker_compose():
        pytest.skip("docker compose not on PATH")
    result = subprocess.run(
        [
            "docker",
            "compose",
            "-f",
            str(COMPOSE_FILE),
            "config",
            "--quiet",
        ],
        capture_output=True,
        text=True,
        cwd=REPO_ROOT,
    )
    assert result.returncode == 0, (
        f"compose config failed: stderr={result.stderr!r}"
    )


def test_compose_declares_required_services():
    compose = yaml.safe_load(COMPOSE_FILE.read_text())
    services = compose.get("services", {})
    assert "backend" in services, "compose must declare a 'backend' service"
    assert "frontend" in services, "compose must declare a 'frontend' service"

    backend = services["backend"]
    backend_volume_sources = {_volume_source(v) for v in backend.get("volumes", [])}
    assert "cyclone_db" in backend_volume_sources, (
        "backend must mount the cyclone_db volume"
    )
    assert backend.get("restart") == "unless-stopped", (
        "backend must restart: unless-stopped so healthcheck failures recover"
    )
    assert "healthcheck" in backend, "backend must declare a healthcheck"

    frontend = services["frontend"]
    assert "8080:8080" in frontend.get("ports", []), (
        "frontend must publish 8080:8080 for LAN access"
    )
    depends_on = frontend.get("depends_on") or {}
    if isinstance(depends_on, dict):
        backend_dep = depends_on.get("backend") or {}
        assert backend_dep.get("condition") == "service_healthy", (
            "frontend must wait for backend healthy before starting"
        )
    else:
        # Short-form `depends_on: [backend]` is acceptable too — it implies
        # service_started, not service_healthy. Flag a soft warning.
        pytest.skip(
            "frontend uses short-form depends_on; switch to long-form for service_healthy"
        )


def test_compose_declares_required_secrets_and_volumes():
    compose = yaml.safe_load(COMPOSE_FILE.read_text())
    secrets = compose.get("secrets", {})
    for required in ("cyclone_db_key", "cyclone_admin_password", "cyclone_sftp_password"):
        assert required in secrets, f"compose must declare secret {required!r}"
    # SP26: the SFTP secret must point at the same /etc/cyclone/secrets/ tree
    # the host operator manages. Catch typos in the file path here so a
    # rename breaks the test, not a production deploy.
    assert (
        secrets["cyclone_sftp_password"]["file"]
        == "/etc/cyclone/secrets/sftp_password"
    )
    volumes = compose.get("volumes", {})
    for required in (
        "cyclone_db",
        "cyclone_backups",
        "cyclone_prodfiles",
        "cyclone_sftp_staging",
        "cyclone_logs",
    ):
        assert required in volumes, f"compose must declare volume {required!r}"


def test_compose_backend_wires_backup_autostart():
    """The existing BackupService (SP17) needs CYCLONE_BACKUP_AUTOSTART=1
    on container boot. The compose env block must include it."""
    compose = yaml.safe_load(COMPOSE_FILE.read_text())
    env = compose["services"]["backend"].get("environment", {})
    assert str(env.get("CYCLONE_BACKUP_AUTOSTART")) == "1", (
        "backend must autostart the backup scheduler (CYCLONE_BACKUP_AUTOSTART=1)"
    )
    assert "CYCLONE_BACKUP_INTERVAL_HOURS" in env
    assert "CYCLONE_BACKUP_RETENTION_DAYS" in env


def test_compose_backend_wires_sftp_password_file_env_var():
    """SP26 — the backend must wire CYCLONE_SFTP_PASSWORD_FILE to the
    mounted Docker secret so the MFT password resolves from
    /run/secrets/cyclone_sftp_password without an env-var export."""
    compose = yaml.safe_load(COMPOSE_FILE.read_text())
    backend = compose["services"]["backend"]
    env = backend.get("environment", {})
    assert env.get("CYCLONE_SFTP_PASSWORD_FILE") == "/run/secrets/cyclone_sftp_password", (
        "backend must wire CYCLONE_SFTP_PASSWORD_FILE to the Docker secret mount"
    )
    backend_secrets = backend.get("secrets", [])
    assert any(
        _volume_source(s) == "cyclone_sftp_password" for s in backend_secrets
    ), "backend must reference the cyclone_sftp_password Docker secret"


@pytest.mark.skipif(
    not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check"
)
def test_backend_dockerfile_parses():
    result = subprocess.run(
        [
            "docker",
            "build",
            "--check",
            "-f",
            str(BACKEND_DOCKERFILE),
            str(REPO_ROOT / "backend"),
        ],
        capture_output=True,
        text=True,
    )
    assert result.returncode == 0, (
        f"backend Dockerfile failed to parse: stderr={result.stderr!r}"
    )


@pytest.mark.skipif(
    not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check"
)
def test_frontend_dockerfile_parses():
    result = subprocess.run(
        [
            "docker",
            "build",
            "--check",
            "-f",
            str(FRONTEND_DOCKERFILE),
            str(REPO_ROOT),
        ],
        capture_output=True,
        text=True,
    )
    assert result.returncode == 0, (
        f"frontend Dockerfile failed to parse: stderr={result.stderr!r}"
    )


@pytest.mark.skipif(
    not _has_docker_compose()
    or not os.environ.get("DOCKER_TESTS"),
    reason="DOCKER_TESTS=1 + docker compose required for live bring-up",
)
def test_compose_up_brings_up_healthy_stack():
    """Gated live test — only runs when DOCKER_TESTS=1 and docker compose
    is available. Builds + brings up the full stack and waits up to 120s
    for the backend healthcheck to come up healthy. Uses the override
    file (docker-compose.override.yml) when present so the test doesn't
    require sudo to create /etc/cyclone/secrets/."""
    # The stack publishes host port 8080. If something else on this host
    # is already using it (e.g. nocodb on a dev box) the test can't run
    # here but will run cleanly on a fresh CI worker. Skip with a clear
    # message rather than failing with a confusing port-bind error.
    import socket

    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        if s.connect_ex(("127.0.0.1", 8080)) == 0:
            pytest.skip("host port 8080 already bound — rerun on a fresh host")

    override = REPO_ROOT / "docker-compose.override.yml"
    cmd_base = ["docker", "compose", "-f", str(COMPOSE_FILE)]
    if override.exists():
        cmd_base.extend(["-f", str(override)])
    subprocess.run(
        cmd_base + ["up", "-d", "--build"],
        check=True,
        cwd=REPO_ROOT,
    )
    try:
        import time

        for _ in range(60):
            ps = subprocess.run(
                cmd_base + ["ps", "--format", "json"],
                capture_output=True,
                text=True,
                cwd=REPO_ROOT,
            )
            if "healthy" in ps.stdout:
                return
            time.sleep(2)
        pytest.fail("compose stack did not become healthy within 120s")
    finally:
        subprocess.run(
            cmd_base + ["down", "-v"],
            check=False,
            cwd=REPO_ROOT,
        )
