"""CLI entrypoint for LangGraph API server."""

import os
import pathlib
import shutil
import sys
from collections.abc import Sequence

import click
import click.exceptions

import langgraph_cli.config
import langgraph_cli.docker
from langgraph_cli.analytics import log_command
from langgraph_cli.config import Config
from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT
from langgraph_cli.deploy import deploy
from langgraph_cli.docker import DockerCapabilities, build_docker_image
from langgraph_cli.exec import Runner, subp_exec
from langgraph_cli.progress import Progress
from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new
from langgraph_cli.util import warn_non_wolfi_distro
from langgraph_cli.version import __version__

# ---------------------------------------------------------------------------
# Shared Click options (non-deploy)
# ---------------------------------------------------------------------------

OPT_DOCKER_COMPOSE = click.option(
    "--docker-compose",
    "-d",
    help="Advanced: Path to docker-compose.yml file with additional services to launch.",
    type=click.Path(
        exists=True,
        file_okay=True,
        dir_okay=False,
        resolve_path=True,
        path_type=pathlib.Path,
    ),
)
OPT_CONFIG = click.option(
    "--config",
    "-c",
    help="""Path to configuration file declaring dependencies, graphs and environment variables.

    \b
    Config file must be a JSON file that has the following keys:
    - "dependencies": array of dependencies for langgraph API server. Dependencies can be one of the following:
      - ".", which would look for local python packages, as well as pyproject.toml, setup.py or requirements.txt in the app directory
      - "./local_package"
      - "<package_name>
    - "graphs": mapping from graph ID to path where the compiled graph is defined, i.e. ./your_package/your_file.py:variable, where
        "variable" is an instance of langgraph.graph.graph.CompiledGraph
    - "env": (optional) path to .env file or a mapping from environment variable to its value
    - "python_version": (optional) 3.11, 3.12, or 3.13. Defaults to 3.11
    - "pip_config_file": (optional) path to pip config file
    - "dockerfile_lines": (optional) array of additional lines to add to Dockerfile following the import from parent image

    \b
    Example:
        langgraph up -c langgraph.json

    \b
    Example:
    {
        "dependencies": [
            "langchain_openai",
            "./your_package"
        ],
        "graphs": {
            "my_graph_id": "./your_package/your_file.py:variable"
        },
        "env": "./.env"
    }

    \b
    Example:
    {
        "python_version": "3.11",
        "dependencies": [
            "langchain_openai",
            "."
        ],
        "graphs": {
            "my_graph_id": "./your_package/your_file.py:variable"
        },
        "env": {
            "OPENAI_API_KEY": "secret-key"
        }
    }

    Defaults to looking for langgraph.json in the current directory.""",
    default=DEFAULT_CONFIG,
    type=click.Path(
        exists=True,
        file_okay=True,
        dir_okay=False,
        resolve_path=True,
        path_type=pathlib.Path,
    ),
)
OPT_PORT = click.option(
    "--port",
    "-p",
    type=int,
    default=DEFAULT_PORT,
    show_default=True,
    help="""
    Port to expose.

    \b
    Example:
        langgraph up --port 8000
    \b
    """,
)
OPT_RECREATE = click.option(
    "--recreate/--no-recreate",
    default=False,
    show_default=True,
    help="Recreate containers even if their configuration and image haven't changed",
)
OPT_PULL = click.option(
    "--pull/--no-pull",
    default=True,
    show_default=True,
    help="""
    Pull latest images. Use --no-pull for running the server with locally-built images.

    \b
    Example:
        langgraph up --no-pull
    \b
    """,
)
OPT_VERBOSE = click.option(
    "--verbose",
    is_flag=True,
    default=False,
    help="Show more output from the server logs",
)
OPT_WATCH = click.option("--watch", is_flag=True, help="Restart on file changes")
OPT_DEBUGGER_PORT = click.option(
    "--debugger-port",
    type=int,
    help="Pull the debugger image locally and serve the UI on specified port",
)
OPT_DEBUGGER_BASE_URL = click.option(
    "--debugger-base-url",
    type=str,
    help="URL used by the debugger to access LangGraph API. Defaults to http://127.0.0.1:[PORT]",
)

OPT_POSTGRES_URI = click.option(
    "--postgres-uri",
    help="Postgres URI to use for the database. Defaults to launching a local database",
)

OPT_API_VERSION = click.option(
    "--api-version",
    type=str,
    help=(
        "API server version to use for the base image. If unspecified, the "
        "latest stable version will be used. Compatible ranges like "
        "~=0.11.0.dev5 stay on 0.11.0.dev5 while only newer dev builds exist, "
        "then resolve to the newest matching rc or stable release, for example "
        "0.11.0rc1 or 0.11.0. Stable-floating ranges like >~=0.11.0.dev5 "
        "can also pick up future stable releases, for example 0.12.0."
    ),
)

OPT_ENGINE_RUNTIME_MODE = click.option(
    "--engine-runtime-mode",
    type=click.Choice(["combined_queue_worker", "distributed"]),
    default="combined_queue_worker",
    help="Runtime mode. 'distributed' uses separate executor and orchestrator containers.",
)


# ---------------------------------------------------------------------------
# Top-level CLI group
# ---------------------------------------------------------------------------


class NestedHelpGroup(click.Group):
    """Click group that shows one level of nested subcommands in top-level help."""

    def format_commands(
        self, ctx: click.Context, formatter: click.HelpFormatter
    ) -> None:
        command_entries: list[tuple[str, click.Command]] = []

        def collect_commands(
            group: click.Group, parent_ctx: click.Context, prefix: str = ""
        ) -> None:
            for command_name in group.list_commands(parent_ctx):
                command = group.get_command(parent_ctx, command_name)
                if command is None or command.hidden:
                    continue
                qualified_name = f"{prefix} {command_name}" if prefix else command_name
                command_entries.append((qualified_name, command))
                if isinstance(command, click.Group):
                    sub_ctx = click.Context(
                        command,
                        info_name=qualified_name,
                        parent=parent_ctx,
                    )
                    collect_commands(command, sub_ctx, qualified_name)

        collect_commands(self, ctx)

        # Compute the available width for help text up front so we can truncate
        # descriptions before handing them to Click. That keeps each command on
        # a single line instead of allowing wrapped descriptions.
        command_width = max((len(name) for name, _ in command_entries), default=0)
        help_width = max(formatter.width - command_width - 6, 10)
        rows = [
            (name, command.get_short_help_str(help_width))
            for name, command in command_entries
        ]

        if rows:
            # Render the flattened command list using Click's standard
            # definition-list formatter so alignment stays consistent with the
            # rest of the CLI help output.
            with formatter.section("Commands"):
                formatter.write_dl(rows)


@click.group(cls=NestedHelpGroup)
@click.version_option(version=__version__, prog_name="LangGraph CLI")
def cli():
    pass


# Wire the deploy group (defined in deploy.py) into the top-level CLI.
cli.add_command(deploy)


# ---------------------------------------------------------------------------
# up command
# ---------------------------------------------------------------------------


@OPT_RECREATE
@OPT_PULL
@OPT_PORT
@OPT_DOCKER_COMPOSE
@OPT_CONFIG
@OPT_VERBOSE
@OPT_DEBUGGER_PORT
@OPT_DEBUGGER_BASE_URL
@OPT_WATCH
@OPT_POSTGRES_URI
@OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE
@click.option(
    "--image",
    type=str,
    default=None,
    help="Docker image to use for the langgraph-api service. If specified, skips building and uses this image directly."
    " Useful if you want to test against an image already built using `langgraph build`.",
)
@click.option(
    "--base-image",
    default=None,
    help="Base image to use for the LangGraph API server. Pin to specific versions using version tags. Defaults to langchain/langgraph-api or langchain/langgraphjs-api."
    "\n\n    \b\nExamples:\n    --base-image langchain/langgraph-server:0.2.18  # Pin to a specific patch version"
    "\n    --base-image langchain/langgraph-server:0.2  # Pin to a minor version (Python)",
)
@click.option(
    "--wait",
    is_flag=True,
    help="Wait for services to start before returning. Implies --detach",
)
@cli.command(help="🚀 Launch LangGraph API server.")
@log_command
def up(
    config: pathlib.Path,
    docker_compose: pathlib.Path | None,
    port: int,
    recreate: bool,
    pull: bool,
    watch: bool,
    wait: bool,
    verbose: bool,
    debugger_port: int | None,
    debugger_base_url: str | None,
    postgres_uri: str | None,
    api_version: str | None,
    engine_runtime_mode: str,
    image: str | None,
    base_image: str | None,
):
    click.secho("Starting LangGraph API server...", fg="green")
    click.secho(
        """For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY.""",
    )
    with Runner() as runner, Progress(message="Pulling...") as set:
        capabilities = langgraph_cli.docker.check_capabilities(runner)
        args, stdin = prepare(
            runner,
            capabilities=capabilities,
            config_path=config,
            docker_compose=docker_compose,
            port=port,
            pull=pull,
            watch=watch,
            verbose=verbose,
            debugger_port=debugger_port,
            debugger_base_url=debugger_base_url,
            postgres_uri=postgres_uri,
            api_version=api_version,
            engine_runtime_mode=engine_runtime_mode,
            image=image,
            base_image=base_image,
        )
        # add up + options
        args.extend(["up", "--remove-orphans"])
        if recreate:
            args.extend(["--force-recreate", "--renew-anon-volumes"])
            try:
                runner.run(subp_exec("docker", "volume", "rm", "langgraph-data"))
            except click.exceptions.Exit:
                pass
        if watch:
            args.append("--watch")
        if wait:
            args.append("--wait")
        else:
            args.append("--abort-on-container-exit")
        # run docker compose
        set("Building...")

        def on_stdout(line: str):
            if "unpacking to docker.io" in line:
                set("Starting...")
            elif "Application startup complete" in line:
                debugger_origin = (
                    f"http://localhost:{debugger_port}"
                    if debugger_port
                    else "https://smith.langchain.com"
                )
                debugger_base_url_query = (
                    debugger_base_url or f"http://127.0.0.1:{port}"
                )
                set("")
                sys.stdout.write(
                    f"""Ready!
- API: http://localhost:{port}
- Docs: http://localhost:{port}/docs
- LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}
"""
                )
                sys.stdout.flush()
                return True

        if capabilities.compose_type == "plugin":
            compose_cmd = ["docker", "compose"]
        elif capabilities.compose_type == "standalone":
            compose_cmd = ["docker-compose"]

        runner.run(
            subp_exec(
                *compose_cmd,
                *args,
                input=stdin,
                verbose=verbose,
                on_stdout=on_stdout,
            )
        )


# ---------------------------------------------------------------------------
# build command
# ---------------------------------------------------------------------------


@OPT_CONFIG
@OPT_PULL
@click.option(
    "--tag",
    "-t",
    help="""Tag for the docker image.

    \b
    Example:
        langgraph build -t my-image

    \b
    """,
    required=True,
)
@click.option(
    "--base-image",
    help="Base image to use for the LangGraph API server. Pin to specific versions using version tags. Defaults to langchain/langgraph-api or langchain/langgraphjs-api."
    "\n\n    \b\nExamples:\n    --base-image langchain/langgraph-server:0.2.18  # Pin to a specific patch version"
    "\n    --base-image langchain/langgraph-server:0.2  # Pin to a minor version (Python)",
)
@OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE
@click.option(
    "--install-command",
    help="Custom install command to run from the build context root. If not provided, auto-detects based on package manager files.",
)
@click.option(
    "--build-command",
    help="Custom build command to run from the langgraph.json directory. If not provided, uses default build process.",
)
@click.argument("docker_build_args", nargs=-1, type=click.UNPROCESSED)
@cli.command(
    help="📦 Build LangGraph API server Docker image.",
    context_settings=dict(
        ignore_unknown_options=True,
    ),
)
@log_command
def build(
    config: pathlib.Path,
    docker_build_args: Sequence[str],
    base_image: str | None,
    api_version: str | None,
    engine_runtime_mode: str,
    pull: bool,
    tag: str,
    install_command: str | None,
    build_command: str | None,
):
    if install_command and langgraph_cli.config.has_disallowed_build_command_content(
        install_command
    ):
        raise click.UsageError(
            "install_command contains disallowed characters or patterns."
        )
    if build_command and langgraph_cli.config.has_disallowed_build_command_content(
        build_command
    ):
        raise click.UsageError(
            "build_command contains disallowed characters or patterns."
        )
    with Runner() as runner, Progress(message="Pulling...") as set:
        if shutil.which("docker") is None:
            raise click.UsageError("Docker not installed") from None
        config_json = langgraph_cli.config.validate_config_file(config)
        warn_non_wolfi_distro(config_json)
        effective_base_image = base_image
        if engine_runtime_mode == "distributed" and not base_image:
            effective_base_image = langgraph_cli.config.default_base_image(
                config_json, engine_runtime_mode=engine_runtime_mode
            )
        build_docker_image(
            runner,
            set,
            config,
            config_json,
            effective_base_image,
            api_version,
            pull,
            tag,
            docker_build_args,
            install_command,
            build_command,
        )


# ---------------------------------------------------------------------------
# dockerfile command
# ---------------------------------------------------------------------------


def _get_docker_ignore_content() -> str:
    """Return the content of a .dockerignore file.

    This file is used to exclude files and directories from the Docker build context.

    It may be overly broad, but it's better to be safe than sorry.

    The main goal is to exclude .env files by default.
    """
    return """\
# Ignore node_modules and other dependency directories
node_modules
bower_components
vendor

# Ignore logs and temporary files
*.log
*.tmp
*.swp

# Ignore .env files and other environment files
.env
.env.*
*.local

# Ignore git-related files
.git
.gitignore

# Ignore Docker-related files and configs
.dockerignore
docker-compose.yml

# Ignore build and cache directories
dist
build
.cache
__pycache__

# Ignore IDE and editor configurations
.vscode
.idea
*.sublime-project
*.sublime-workspace
.DS_Store  # macOS-specific

# Ignore test and coverage files
coverage
*.coverage
*.test.js
*.spec.js
tests
"""


@OPT_CONFIG
@click.argument("save_path", type=click.Path(resolve_path=True))
@cli.command(
    help="🐳 Generate a Dockerfile for the LangGraph API server, with Docker Compose options."
)
@click.option(
    "--add-docker-compose",
    help=(
        "Add additional files for running the LangGraph API server with "
        "docker-compose. These files include a docker-compose.yml, .env file, "
        "and a .dockerignore file."
    ),
    is_flag=True,
)
@click.option(
    "--base-image",
    help="Base image to use for the LangGraph API server. Pin to specific versions using version tags. Defaults to langchain/langgraph-api or langchain/langgraphjs-api."
    "\n\n    \b\nExamples:\n    --base-image langchain/langgraph-server:0.2.18  # Pin to a specific patch version"
    "\n    --base-image langchain/langgraph-server:0.2  # Pin to a minor version (Python)",
)
@OPT_API_VERSION
@OPT_ENGINE_RUNTIME_MODE
@log_command
def dockerfile(
    save_path: str,
    config: pathlib.Path,
    add_docker_compose: bool,
    base_image: str | None = None,
    api_version: str | None = None,
    engine_runtime_mode: str = "combined_queue_worker",
) -> None:
    from click import secho

    save_path = pathlib.Path(save_path).absolute()
    secho(f"🔍 Validating configuration at path: {config}", fg="yellow")
    config_json = langgraph_cli.config.validate_config_file(config)
    warn_non_wolfi_distro(config_json)
    secho("✅ Configuration validated!", fg="green")

    effective_base_image = base_image
    if engine_runtime_mode == "distributed" and not base_image:
        effective_base_image = langgraph_cli.config.default_base_image(
            config_json, engine_runtime_mode=engine_runtime_mode
        )

    secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow")
    dockerfile_content, additional_contexts = langgraph_cli.config.config_to_docker(
        config_path=config,
        config=config_json,
        base_image=effective_base_image,
        api_version=api_version,
    )
    with open(str(save_path), "w", encoding="utf-8") as f:
        f.write(dockerfile_content)
    secho("✅ Created: Dockerfile", fg="green")

    if additional_contexts:
        additional_contexts_str = ",".join(
            f"{k}={v}" for k, v in additional_contexts.items()
        )
        secho(
            f"""📝 Run docker build with these additional build contexts `--build-context {additional_contexts_str}`""",
            fg="yellow",
        )

    if add_docker_compose:
        # Add docker compose and related files
        # Add .dockerignore file in the same directory as the Dockerfile
        with open(str(save_path.parent / ".dockerignore"), "w", encoding="utf-8") as f:
            f.write(_get_docker_ignore_content())
        secho("✅ Created: .dockerignore", fg="green")

        # Generate a docker-compose.yml file
        path = str(save_path.parent / "docker-compose.yml")
        with open(path, "w", encoding="utf-8") as f:
            with Runner() as runner:
                capabilities = langgraph_cli.docker.check_capabilities(runner)

            compose_dict = langgraph_cli.docker.compose_as_dict(
                capabilities,
                port=8123,
                base_image=base_image,
            )
            # Add .env file to the docker-compose.yml for the langgraph-api service
            compose_dict["services"]["langgraph-api"]["env_file"] = [".env"]
            # Add the Dockerfile to the build context
            compose_dict["services"]["langgraph-api"]["build"] = {
                "context": ".",
                "dockerfile": save_path.name,
            }
            # Add the base_image as build arg if provided
            if base_image:
                compose_dict["services"]["langgraph-api"]["build"]["args"] = {
                    "BASE_IMAGE": base_image
                }
            f.write(langgraph_cli.docker.dict_to_yaml(compose_dict))
            secho("✅ Created: docker-compose.yml", fg="green")

        # Check if the .env file exists in the same directory as the Dockerfile
        if not (save_path.parent / ".env").exists():
            # Also add an empty .env file
            with open(str(save_path.parent / ".env"), "w", encoding="utf-8") as f:
                f.writelines(
                    [
                        "# Uncomment the following line to add your LangSmith API key",
                        "\n",
                        "# LANGSMITH_API_KEY=your-api-key",
                        "\n",
                        "# Or if you have a LangSmith Deployment license key, "
                        "then uncomment the following line: ",
                        "\n",
                        "# LANGGRAPH_CLOUD_LICENSE_KEY=your-license-key",
                        "\n",
                        "# Add any other environment variables go below...",
                    ]
                )

            secho("✅ Created: .env", fg="green")
        else:
            # Do nothing since the .env file already exists. Not a great
            # idea to overwrite in case the user has added custom env vars set
            # in the .env file already.
            secho("➖ Skipped: .env. It already exists!", fg="yellow")

    secho(
        f"🎉 Files generated successfully at path {save_path.parent}!",
        fg="cyan",
        bold=True,
    )


# ---------------------------------------------------------------------------
# dev command
# ---------------------------------------------------------------------------


@click.option(
    "--host",
    default="127.0.0.1",
    help="Network interface to bind the development server to. Default 127.0.0.1 is recommended for security. Only use 0.0.0.0 in trusted networks",
)
@click.option(
    "--port",
    default=2024,
    type=int,
    help="Port number to bind the development server to. Example: langgraph dev --port 8000",
)
@click.option(
    "--no-reload",
    is_flag=True,
    help="Disable automatic reloading when code changes are detected",
)
@click.option(
    "--config",
    type=click.Path(exists=True),
    default="langgraph.json",
    help="Path to configuration file declaring dependencies, graphs and environment variables",
)
@click.option(
    "--n-jobs-per-worker",
    default=None,
    type=int,
    help="Maximum number of concurrent jobs each worker process can handle. Default: 10",
)
@click.option(
    "--no-browser",
    is_flag=True,
    help="Skip automatically opening the browser when the server starts",
)
@click.option(
    "--debug-port",
    default=None,
    type=int,
    help="Enable remote debugging by listening on specified port. Requires debugpy to be installed",
)
@click.option(
    "--wait-for-client",
    is_flag=True,
    help="Wait for a debugger client to connect to the debug port before starting the server",
    default=False,
)
@click.option(
    "--studio-url",
    type=str,
    default=None,
    help="URL of the LangGraph Studio instance to connect to. Defaults to https://smith.langchain.com",
)
@click.option(
    "--allow-blocking",
    is_flag=True,
    help="Don't raise errors for synchronous I/O blocking operations in your code.",
    default=False,
)
@click.option(
    "--tunnel",
    is_flag=True,
    help="Expose the local server via a public tunnel (in this case, Cloudflare) "
    "for remote frontend access. This avoids issues with browsers "
    "or networks blocking localhost connections.",
    default=False,
)
@click.option(
    "--server-log-level",
    type=str,
    default="WARNING",
    help="Set the log level for the API server.",
)
@click.option(
    "--ssl-certfile",
    type=click.Path(
        exists=True,
        file_okay=True,
        dir_okay=False,
        resolve_path=True,
        path_type=pathlib.Path,
    ),
    default=None,
    help="Path to an SSL certificate file for serving the development server over HTTPS.",
)
@click.option(
    "--ssl-keyfile",
    type=click.Path(
        exists=True,
        file_okay=True,
        dir_okay=False,
        resolve_path=True,
        path_type=pathlib.Path,
    ),
    default=None,
    help="Path to an SSL key file for serving the development server over HTTPS.",
)
@cli.command(
    "dev",
    help="🏃‍♀️‍➡️ Run LangGraph API server in development mode with hot reloading and debugging support",
)
@log_command
def dev(
    host: str,
    port: int,
    no_reload: bool,
    config: str,
    n_jobs_per_worker: int | None,
    no_browser: bool,
    debug_port: int | None,
    wait_for_client: bool,
    studio_url: str | None,
    allow_blocking: bool,
    tunnel: bool,
    server_log_level: str,
    ssl_certfile: pathlib.Path | None,
    ssl_keyfile: pathlib.Path | None,
):
    """CLI entrypoint for running the LangGraph API server."""
    if (ssl_certfile is None) != (ssl_keyfile is None):
        raise click.UsageError(
            "Both --ssl-certfile and --ssl-keyfile must be provided to enable HTTPS."
        )

    if ssl_certfile and ssl_keyfile and tunnel:
        raise click.UsageError(
            "Cannot use --tunnel with SSL options. Please choose either to serve over HTTPS or to expose via a tunnel, but not both."
        )

    try:
        from langgraph_api.cli import run_server  # type: ignore
    except ImportError:
        py_version_msg = ""
        if sys.version_info < (3, 11):
            py_version_msg = (
                "\n\nNote: The in-mem server requires Python 3.11 or higher to be installed."
                f" You are currently using Python {sys.version_info.major}.{sys.version_info.minor}."
                ' Please upgrade your Python version before installing "langgraph-cli[inmem]".'
            )
        try:
            from importlib import util

            if not util.find_spec("langgraph_api"):
                raise click.UsageError(
                    "Required package 'langgraph-api' is not installed.\n"
                    "Please install it with:\n\n"
                    '    pip install -U "langgraph-cli[inmem]"'
                    f"{py_version_msg}"
                ) from None
        except ImportError:
            raise click.UsageError(
                "Could not verify package installation. Please ensure Python is up to date and\n"
                "langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\""
                f"{py_version_msg}"
            ) from None
        raise click.UsageError(
            "Could not import run_server. This likely means your installation is incomplete.\n"
            "Please ensure langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\""
            f"{py_version_msg}"
        ) from None

    config_json = langgraph_cli.config.validate_config_file(pathlib.Path(config))
    if config_json.get("node_version"):
        raise click.UsageError(
            "In-mem server for JS graphs is not supported in this version of the LangGraph CLI. Please use `npx @langchain/langgraph-cli` instead."
        ) from None

    cwd = os.getcwd()
    sys.path.append(cwd)
    dependencies = config_json.get("dependencies", [])
    for dep in dependencies:
        dep_path = pathlib.Path(cwd) / dep
        if dep_path.is_dir() and dep_path.exists():
            sys.path.append(str(dep_path))

    graphs = config_json.get("graphs", {})

    run_server(
        host,
        port,
        not no_reload,
        graphs,
        n_jobs_per_worker=n_jobs_per_worker,
        open_browser=not no_browser,
        debug_port=debug_port,
        env=config_json.get("env"),
        store=config_json.get("store"),
        wait_for_client=wait_for_client,
        auth=config_json.get("auth"),
        http=config_json.get("http"),
        ui=config_json.get("ui"),
        ui_config=config_json.get("ui_config"),
        webhooks=config_json.get("webhooks"),
        studio_url=studio_url,
        allow_blocking=allow_blocking,
        tunnel=tunnel,
        server_level=server_log_level,
        checkpointer=config_json.get("checkpointer"),
        disable_persistence=config_json.get("disable_persistence", False),
        ssl_certfile=ssl_certfile,
        ssl_keyfile=ssl_keyfile,
    )


# ---------------------------------------------------------------------------
# validate command
# ---------------------------------------------------------------------------


@OPT_CONFIG
@cli.command(help="✅ Validate the LangGraph configuration file.")
@log_command
def validate(config: pathlib.Path):
    import json

    try:
        with open(config) as f:
            raw_config = json.load(f)
    except json.JSONDecodeError as e:
        raise click.UsageError(f"Invalid JSON in {config}: {e.args[0]}") from None

    # Check for unknown keys before validation so they show alongside any error.
    unknown_warnings = langgraph_cli.config.get_unknown_keys(raw_config)

    try:
        config_json = langgraph_cli.config.validate_config_file(config)
    except (click.UsageError, ValueError) as e:
        click.secho(f"Error: {e}", fg="red", err=True)
        if unknown_warnings:
            click.echo(err=True)
            for warning in unknown_warnings:
                click.secho(f"  warning: {warning}", fg="yellow", err=True)
        raise SystemExit(1) from None

    num_graphs = len(config_json.get("graphs", {}))
    click.secho(
        f"Configuration file {config} is valid. "
        f"({num_graphs} graph{'s' if num_graphs != 1 else ''} found)",
        fg="green",
    )
    if unknown_warnings:
        click.echo()
        for warning in unknown_warnings:
            click.secho(f"  warning: {warning}", fg="yellow")


# ---------------------------------------------------------------------------
# new command
# ---------------------------------------------------------------------------


@click.argument("path", required=False)
@click.option(
    "--template",
    type=str,
    help=TEMPLATE_HELP_STRING,
)
@cli.command("new", help="🌱 Create a new LangGraph project from a template.")
@log_command
def new(path: str | None, template: str | None) -> None:
    """Create a new LangGraph project from a template."""
    return create_new(path, template)


# ---------------------------------------------------------------------------
# Compose helpers (used by up and tests)
# ---------------------------------------------------------------------------


def prepare_args_and_stdin(
    *,
    capabilities: DockerCapabilities,
    config_path: pathlib.Path,
    config: Config,
    docker_compose: pathlib.Path | None,
    port: int,
    watch: bool,
    debugger_port: int | None = None,
    debugger_base_url: str | None = None,
    postgres_uri: str | None = None,
    api_version: str | None = None,
    engine_runtime_mode: str = "combined_queue_worker",
    # Like "my-tag" (if you already built it locally)
    image: str | None = None,
    # Like "langchain/langgraphjs-api" or "langchain/langgraph-api
    base_image: str | None = None,
) -> tuple[list[str], str]:
    assert config_path.exists(), f"Config file not found: {config_path}"
    # prepare args
    stdin = langgraph_cli.docker.compose(
        capabilities,
        port=port,
        debugger_port=debugger_port,
        debugger_base_url=debugger_base_url,
        postgres_uri=postgres_uri,
        image=image,
        base_image=base_image,
        api_version=api_version,
        engine_runtime_mode=engine_runtime_mode,
    )
    args = [
        "--project-directory",
        str(config_path.parent),
    ]
    # apply options
    if docker_compose:
        args.extend(["-f", str(docker_compose)])
    args.extend(["-f", "-"])  # stdin
    # apply config
    stdin += langgraph_cli.config.config_to_compose(
        config_path,
        config,
        watch=watch,
        base_image=langgraph_cli.config.default_base_image(config),
        api_version=api_version,
        image=image,
        engine_runtime_mode=engine_runtime_mode,
    )
    return args, stdin


def prepare(
    runner,
    *,
    capabilities: DockerCapabilities,
    config_path: pathlib.Path,
    docker_compose: pathlib.Path | None,
    port: int,
    pull: bool,
    watch: bool,
    verbose: bool,
    debugger_port: int | None = None,
    debugger_base_url: str | None = None,
    postgres_uri: str | None = None,
    api_version: str | None = None,
    engine_runtime_mode: str = "combined_queue_worker",
    image: str | None = None,
    base_image: str | None = None,
) -> tuple[list[str], str]:
    """Prepare the arguments and stdin for running the LangGraph API server."""
    config_json = langgraph_cli.config.validate_config_file(config_path)
    warn_non_wolfi_distro(config_json)
    # pull latest images
    if pull:
        runner.run(
            subp_exec(
                "docker",
                "pull",
                langgraph_cli.config.docker_tag(config_json, base_image, api_version),
                verbose=verbose,
            )
        )
        if engine_runtime_mode == "distributed":
            executor_base = langgraph_cli.config.default_base_image(
                config_json, engine_runtime_mode="distributed"
            )
            runner.run(
                subp_exec(
                    "docker",
                    "pull",
                    langgraph_cli.config.docker_tag(
                        config_json, executor_base, api_version
                    ),
                    verbose=verbose,
                )
            )

    args, stdin = prepare_args_and_stdin(
        capabilities=capabilities,
        config_path=config_path,
        config=config_json,
        docker_compose=docker_compose,
        port=port,
        watch=watch,
        debugger_port=debugger_port,
        debugger_base_url=debugger_base_url or f"http://127.0.0.1:{port}",
        postgres_uri=postgres_uri,
        api_version=api_version,
        engine_runtime_mode=engine_runtime_mode,
        image=image,
        base_image=base_image,
    )
    return args, stdin
