import copy
import json
import pathlib
import platform
import shutil
from collections.abc import Callable, Sequence
from typing import Literal, NamedTuple

import click.exceptions

import langgraph_cli.config
from langgraph_cli.exec import subp_exec

ROOT = pathlib.Path(__file__).parent.resolve()
DEFAULT_POSTGRES_URI = (
    "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable"
)


class Version(NamedTuple):
    major: int
    minor: int
    patch: int


DockerComposeType = Literal["plugin", "standalone"]


class DockerCapabilities(NamedTuple):
    version_docker: Version
    version_compose: Version
    healthcheck_start_interval: bool
    compose_type: DockerComposeType = "plugin"


def _parse_version(version: str) -> Version:
    parts = version.split(".", 2)
    if len(parts) == 1:
        major = parts[0]
        minor = "0"
        patch = "0"
    elif len(parts) == 2:
        major, minor = parts
        patch = "0"
    else:
        major, minor, patch = parts
    return Version(
        int(major.lstrip("v")), int(minor), int(patch.split("-")[0].split("+")[0])
    )


def can_build_locally() -> tuple[bool, str | None]:
    """Return whether local deployment builds can run on this machine.

    Checks:
    - Docker binary is installed
    - Docker daemon is running
    - Buildx is available when cross-compilation is required (non-x86_64)
    """
    if shutil.which("docker") is None:
        return (
            False,
            "Docker is required but not installed.\n"
            "Install Docker Desktop: https://docs.docker.com/get-docker/",
        )
    try:
        import subprocess

        docker_info = subprocess.run(
            ["docker", "info"],
            capture_output=True,
            timeout=10,
        )
        if docker_info.returncode != 0:
            return (
                False,
                "Docker is installed but not running.\nStart Docker and try again.",
            )

        if platform.machine() != "x86_64":
            buildx = subprocess.run(
                ["docker", "buildx", "version"],
                capture_output=True,
                timeout=10,
            )
            if buildx.returncode != 0:
                return (
                    False,
                    "Docker Buildx is required but not installed.\n"
                    "Your machine architecture ("
                    + platform.machine()
                    + ") requires Buildx to cross-compile images for linux/amd64.\n"
                    "Install Buildx: https://docs.docker.com/build/install-buildx/",
                )
        return True, None
    except Exception:
        return False, "Unable to verify local Docker build support."


def check_capabilities(runner) -> DockerCapabilities:
    # check docker available
    if shutil.which("docker") is None:
        raise click.UsageError("Docker not installed") from None

    try:
        stdout, _ = runner.run(
            subp_exec("docker", "info", "-f", "{{json .}}", collect=True)
        )
        info = json.loads(stdout)
    except (click.exceptions.Exit, json.JSONDecodeError):
        raise click.UsageError("Docker not installed or not running") from None

    if not info["ServerVersion"]:
        raise click.UsageError("Docker not running") from None

    compose_type: DockerComposeType
    try:
        compose = next(
            p for p in info["ClientInfo"]["Plugins"] if p["Name"] == "compose"
        )
        compose_version_str = compose["Version"]
        compose_type = "plugin"
    except (KeyError, StopIteration):
        if shutil.which("docker-compose") is None:
            raise click.UsageError("Docker Compose not installed") from None

        compose_version_str, _ = runner.run(
            subp_exec("docker-compose", "--version", "--short", collect=True)
        )
        compose_type = "standalone"

    # parse versions
    docker_version = _parse_version(info["ServerVersion"])
    compose_version = _parse_version(compose_version_str)

    # check capabilities
    return DockerCapabilities(
        version_docker=docker_version,
        version_compose=compose_version,
        healthcheck_start_interval=docker_version >= Version(25, 0, 0),
        compose_type=compose_type,
    )


def debugger_compose(*, port: int | None = None, base_url: str | None = None) -> dict:
    if port is None:
        return ""

    config = {
        "langgraph-debugger": {
            "image": "langchain/langgraph-debugger",
            "restart": "on-failure",
            "depends_on": {
                "langgraph-postgres": {"condition": "service_healthy"},
            },
            "ports": [f'"{port}:3968"'],
        }
    }

    if base_url:
        config["langgraph-debugger"]["environment"] = {
            "VITE_STUDIO_LOCAL_GRAPH_URL": base_url
        }

    return config


# Function to convert dictionary to YAML
def dict_to_yaml(d: dict, *, indent: int = 0) -> str:
    """Convert a dictionary to a YAML string."""
    yaml_str = ""

    for idx, (key, value) in enumerate(d.items()):
        # Format things in a visually appealing way
        # Use an extra newline for top-level keys only
        if idx >= 1 and indent < 2:
            yaml_str += "\n"
        space = "    " * indent
        if isinstance(value, dict):
            yaml_str += f"{space}{key}:\n" + dict_to_yaml(value, indent=indent + 1)
        elif isinstance(value, list):
            yaml_str += f"{space}{key}:\n"
            for item in value:
                yaml_str += f"{space}    - {item}\n"
        else:
            yaml_str += f"{space}{key}: {value}\n"
    return yaml_str


def compose_as_dict(
    capabilities: DockerCapabilities,
    *,
    port: int,
    debugger_port: int | None = None,
    debugger_base_url: str | None = None,
    # postgres://user:password@host:port/database?option=value
    postgres_uri: str | None = None,
    # If you are running against an already-built image, you can pass it here
    image: str | None = None,
    # Base image to use for the LangGraph API server
    base_image: str | None = None,
    # API version of the base image
    api_version: str | None = None,
    engine_runtime_mode: str = "combined_queue_worker",
) -> dict:
    """Create a docker compose file as a dictionary in YML style."""
    if postgres_uri is None:
        include_db = True
        postgres_uri = DEFAULT_POSTGRES_URI
    else:
        include_db = False

    # The services below are defined in a non-intuitive order to match
    # the existing unit tests for this function.
    # It's fine to re-order just requires updating the unit tests, so it should
    # be done with caution.

    # Define the Redis service first as per the test order
    services = {
        "langgraph-redis": {
            "image": "redis:6",
            "healthcheck": {
                "test": "redis-cli ping",
                "interval": "5s",
                "timeout": "1s",
                "retries": 5,
            },
        }
    }

    # Add Postgres service before langgraph-api if it is needed
    if include_db:
        services["langgraph-postgres"] = {
            "image": "pgvector/pgvector:pg16",
            "ports": ['"5433:5432"'],
            "environment": {
                "POSTGRES_DB": "postgres",
                "POSTGRES_USER": "postgres",
                "POSTGRES_PASSWORD": "postgres",
            },
            "command": ["postgres", "-c", "shared_preload_libraries=vector"],
            "volumes": ["langgraph-data:/var/lib/postgresql/data"],
            "healthcheck": {
                "test": "pg_isready -U postgres",
                "start_period": "10s",
                "timeout": "1s",
                "retries": 5,
            },
        }
        if capabilities.healthcheck_start_interval:
            services["langgraph-postgres"]["healthcheck"]["interval"] = "60s"
            services["langgraph-postgres"]["healthcheck"]["start_interval"] = "1s"
        else:
            services["langgraph-postgres"]["healthcheck"]["interval"] = "5s"

    # Add optional debugger service if debugger_port is specified
    if debugger_port:
        services["langgraph-debugger"] = debugger_compose(
            port=debugger_port, base_url=debugger_base_url
        )["langgraph-debugger"]

    # Add langgraph-api service
    api_environment = {
        "REDIS_URI": "redis://langgraph-redis:6379",
        "POSTGRES_URI": postgres_uri,
    }
    if engine_runtime_mode == "distributed":
        api_environment["N_JOBS_PER_WORKER"] = '"0"'

    services["langgraph-api"] = {
        "ports": [f'"{port}:8000"'],
        "depends_on": {
            "langgraph-redis": {"condition": "service_healthy"},
        },
        "environment": api_environment,
    }
    if image:
        services["langgraph-api"]["image"] = image

    # If Postgres is included, add it to the dependencies of langgraph-api
    if include_db:
        services["langgraph-api"]["depends_on"]["langgraph-postgres"] = {
            "condition": "service_healthy"
        }

    # Additional healthcheck for langgraph-api if required
    if capabilities.healthcheck_start_interval:
        services["langgraph-api"]["healthcheck"] = {
            "test": "python /api/healthcheck.py",
            "interval": "60s",
            "start_interval": "1s",
            "start_period": "10s",
        }

    # Final compose dictionary with volumes included if needed
    compose_dict = {}
    if include_db:
        compose_dict["volumes"] = {"langgraph-data": {"driver": "local"}}
    compose_dict["services"] = services

    return compose_dict


def compose(
    capabilities: DockerCapabilities,
    *,
    port: int,
    debugger_port: int | None = None,
    debugger_base_url: str | None = None,
    # postgres://user:password@host:port/database?option=value
    postgres_uri: str | None = None,
    image: str | None = None,
    base_image: str | None = None,
    api_version: str | None = None,
    engine_runtime_mode: str = "combined_queue_worker",
) -> str:
    """Create a docker compose file as a string."""
    compose_content = compose_as_dict(
        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,
    )
    compose_str = dict_to_yaml(compose_content)
    return compose_str


def build_docker_image(
    runner,
    set: Callable[[str], None],
    config: pathlib.Path,
    config_json: dict,
    base_image: str | None,
    api_version: str | None,
    pull: bool,
    tag: str,
    passthrough: Sequence[str] = (),
    install_command: str | None = None,
    build_command: str | None = None,
    docker_command: Sequence[str] | None = None,
    extra_flags: Sequence[str] = (),
    verbose: bool = True,
):
    """Build a Docker image from a LangGraph config."""
    # pull latest images
    if pull:
        runner.run(
            subp_exec(
                "docker",
                "pull",
                langgraph_cli.config.docker_tag(config_json, base_image, api_version),
                verbose=verbose,
            )
        )
    set("Building...")
    # apply options
    args = [
        "-f",
        "-",  # stdin
        "-t",
        tag,
    ]
    # determine build context: use current directory for JS projects, config parent for Python
    is_js_project = config_json.get("node_version") and not config_json.get(
        "python_version"
    )
    # build/install commands only apply to JS projects for now
    # without install/build command, JS projects will follow the old behavior
    if is_js_project and (build_command or install_command):
        build_context = str(pathlib.Path.cwd())
    else:
        build_context = str(config.parent)

    # Deep copy to avoid mutating the caller's config (config_to_docker
    # rewrites graph paths to container-internal paths in place).
    config_json = copy.deepcopy(config_json)
    stdin, additional_contexts = langgraph_cli.config.config_to_docker(
        config_path=config,
        config=config_json,
        base_image=base_image,
        api_version=api_version,
        install_command=install_command,
        build_command=build_command,
        build_context=build_context,
    )
    # add additional_contexts
    if additional_contexts:
        for k, v in additional_contexts.items():
            args.extend(["--build-context", f"{k}={v}"])
    cmd = tuple(docker_command) if docker_command else ("docker", "build")
    runner.run(
        subp_exec(
            *cmd,
            *args,
            *extra_flags,
            *passthrough,
            build_context,
            input=stdin,
            verbose=verbose,
        )
    )
